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.

No Token, So Protect the Effect In a normal form flow the server plants a token in the page and validates it on submission. In a share target flow the browser composes the request from the manifest, so there is no page and no token. The protection therefore moves to the effect: the endpoint stages the payload and a separate in-app confirmation performs the commit. Normal form — token works server renders page + hidden token form POSTs token included server validates token → commits a forged cross-site POST has no valid token Share target — no token exists browser composes from the manifest POST arrives nothing to validate stage locally — no account write a forged POST achieves only a staged draft user confirms in-app → tokened request → commit

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.

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.

Four Independent Layers Four defences, each sufficient on its own: the endpoint never commits, the commit path requires a token from a rendered page, SameSite=Lax withholds credentials from cross-site posts, and draft caps with expiry limit the nuisance value of a flood. 1 · endpoint never commits stages a local draft only removes the attacker's goal entirely 2 · commit needs a token issued by a page your server rendered ordinary CSRF protection applies 3 · SameSite=Lax no session cookie on a cross-site POST one response header 4 · draft cap + expiry a flood cannot fill storage or bury real shares nuisance, not compromise The Attacker's Best Case With the endpoint staging rather than committing, the most a forged request achieves is a draft appearing in the review list, which the user can discard. Nothing is written to the account, no credential is used, and a flood is bounded by the draft cap. endpoint commits a link writes to the user's account no interaction required classic cross-site request forgery endpoint stages a draft appears; the user discards it no account write, no credential used a nuisance bounded by the draft cap

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.