Preventing CSRF on POST Share Targets
This guide is part of Securing Incoming Shared Payloads, which itself is part of Web Share Target API.
Cross-site request forgery is normally solved with a token: the server plants an unguessable value in the page, the form returns it, and a request without it is rejected. A share target cannot do that. The browser builds the request from your manifest declaration and the payload the user selected — there is no template for your application to inject a token into, and anything written into the manifest is static and world-readable.
So the defence moves. Instead of authenticating the request, make the endpoint incapable of doing damage: receive, stage locally, and require a normal in-app confirmation before anything is committed.
Feature Detection Gate
The handler starts by classifying the request rather than trusting it.
// classify-request.js
export function classifyShareRequest(request, url) {
const site = request.headers.get('Sec-Fetch-Site'); // browser-set, cannot be forged by a page
const mode = request.headers.get('Sec-Fetch-Mode');
const dest = request.headers.get('Sec-Fetch-Dest');
return {
isPost: request.method === 'POST',
isOwnOrigin: url.origin === self.location.origin,
isNavigation: mode === 'navigate' && dest === 'document',
crossSite: site === 'cross-site',
site
};
}
These headers are set by the browser and are not writable from script, which makes them meaningful — but the value a share navigation produces is not unique to share navigations, so this classification informs logging and heuristics rather than being the authorisation decision itself.
Step 1 — Make the Endpoint Non-Mutating
The share target’s only job is to move the payload into a local staging area and redirect to a review screen. Nothing about the user’s account changes.
// sw-stage-only.js
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (event.request.method === 'POST' && url.pathname === '/share-target') {
event.respondWith(stageAndRedirect(event.request));
}
});
async function stageAndRedirect(request) {
const form = await request.formData();
// Local only. No network write, no account mutation, no side effect a forged request wants.
const draftId = crypto.randomUUID();
await putDraft({
id: draftId,
receivedAt: Date.now(),
title: String(form.get('title') ?? '').slice(0, 300),
text: String(form.get('text') ?? '').slice(0, 20_000),
url: String(form.get('url') ?? '').slice(0, 2048),
files: form.getAll('media').filter((entry) => entry instanceof File)
});
return Response.redirect(`/share-target/review?draft=${draftId}`, 303);
}
This single design decision removes the entire attack. The worst a forged request can now achieve is a draft appearing in the user’s review list, which they can discard — a nuisance, not a compromise. It also happens to be better product behaviour, since the user sees what arrived before it joins their data.
Step 2 — Commit Only from a Real In-App Action
The commit is an ordinary authenticated request from your own page, so every normal protection applies.
// commit-draft.js
export async function commitDraft(draftId, csrfToken) {
const draft = await getDraft(draftId);
if (!draft) throw new Error('draft-not-found');
const response = await fetch('/api/items', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // available here — this is a page, not a share target
},
body: JSON.stringify({
title: draft.title,
text: draft.text,
url: draft.url,
fileIds: draft.fileIds
})
});
if (!response.ok) throw new Error(`commit-failed-${response.status}`);
await deleteDraft(draftId);
return response.json();
}
The token exists in this path because the review screen is a page your server rendered. What was impossible at the share endpoint is routine one step later.
Step 3 — Add Header and Cookie Defences
With the primary design in place, two cheap layers make forged requests less useful still.
// defence-in-depth.js
export function looksForged(request, url) {
const site = request.headers.get('Sec-Fetch-Site');
const mode = request.headers.get('Sec-Fetch-Mode');
// A share navigation arrives as a document navigation. A hidden cross-site
// form POST from a page typically does not match this shape.
if (mode && mode !== 'navigate') return true;
if (site === 'same-origin' && url.pathname === '/share-target') return true; // odd: own page posting here
return false;
}
Set-Cookie: session=…; HttpOnly; Secure; SameSite=Lax; Path=/
SameSite=Lax is the important line, and it is one header. It prevents the session cookie from being attached to a cross-site POST at all, so even if a handler somewhere did try to write to the account, the request would arrive unauthenticated. Combined with a staging endpoint that never writes to the account anyway, that is two independent reasons the attack fails.
Step 4 — Make Drafts Cheap to Discard
Because forged requests can still create drafts, the review surface has to tolerate junk without becoming one.
// draft-hygiene.js
const MAX_DRAFTS = 25;
const DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export async function pruneDrafts(now) {
const drafts = await listDrafts(); // newest first
const expired = drafts.filter((d) => now - d.receivedAt > DRAFT_TTL_MS);
const excess = drafts.slice(MAX_DRAFTS);
const doomed = new Set([...expired, ...excess].map((d) => d.id));
for (const id of doomed) await deleteDraft(id);
return doomed.size;
}
A cap plus an expiry means a flood of forged shares cannot fill storage or bury the user’s real ones — the same reasoning that drives limiting shared payload size in a service worker, applied to record count rather than bytes.
Failure Modes and Recovery
| Symptom | Cause | Minimal fix |
|---|---|---|
| A link silently adds items to an account | Share target commits directly | Stage locally; commit from a confirmed action |
| Attempted token check in the manifest | Tokens cannot be injected there | Move the check to the commit request |
| Authenticated write from a forged POST | Session cookie sent cross-site | SameSite=Lax on the session cookie |
| Review list flooded with junk drafts | No cap or expiry | Prune by age and count |
| Header check used as authorisation | Sec-Fetch-* is not distinctive enough |
Treat it as defence in depth only |
| Draft persists after commit | Delete step missing | Remove the draft in the same flow as the commit |
Browser and Platform Caveat
Sec-Fetch-Site, Sec-Fetch-Mode and Sec-Fetch-Dest are sent by Chromium, Firefox and Safari and cannot be set from page script, so they are trustworthy signals — but they are coarse, and a share navigation does not carry a value unique to share navigations, which is why they support the design rather than constitute it. SameSite=Lax is the default in current Chromium and is honoured everywhere, though it only governs cookies: a handler that writes to local storage without credentials is unaffected by it, which is precisely the gap the staging pattern closes. Since share targets are Chromium-only, the live exposure is Android, ChromeOS and installed desktop PWAs; the endpoint nonetheless remains reachable from any browser by URL, so none of these protections may be made conditional on share-target support being present.
FAQ
Why can a share target not use a CSRF token?
The browser composes the request from the manifest and the payload, so there is no place for your application to inject a per-session token. Any value baked into the manifest would be static and public, which is exactly what a CSRF token must not be.
Does SameSite=Lax already stop a forged share POST?
It stops the session cookie from being attached to a cross-site POST, which prevents an authenticated write. It does not stop the request from arriving, so an unauthenticated handler that writes to local storage is still reachable and still needs the staging pattern.
Can I trust Sec-Fetch-Site to identify a real share?
It is a useful signal because browsers set it and pages cannot forge it, but the value for a share navigation is not distinctive enough to be an authorisation decision on its own. Treat it as defence in depth on top of a design where the endpoint does not commit anything.