Recovering from Expired Presigned URLs Mid-Upload

Treat a 403 from storage as “maybe expired”, read the XML error body — S3 returns <Code>AccessDenied</Code><Message>Request has expired</Message> (or ExpiredToken for temporary credentials) — and when it is an expiry, ask your API for a fresh URL for the same object key, upload ID and part number, then retry only the request that failed. Avoid most expiries in the first place by signing lazily: request each part’s URL just before sending it rather than signing every part when the upload starts, and give URLs an expiry comfortably longer than the slowest expected request. Never treat an expiry as a failed file; the bytes already stored are still valid.

Presigned URLs are deliberately short-lived, and uploads are sometimes slow: a laptop sleeps, a phone switches networks, a queue holds a file for twenty minutes behind others, or a single large part crawls over a weak connection. When the URL expires, the storage service rejects the request with a 403 that looks exactly like a permission error. Clients that treat every 403 as fatal throw away hours of progress for a problem that one API call would fix. This page belongs to upload error recovery patterns in frontend UX, chunking and progress tracking; why URLs can expire earlier than their stated lifetime is explained in why presigned URLs expire early with temporary credentials.

When to use this approach

  • Uploads use presigned PUT URLs or presigned multipart part URLs.
  • Uploads can take longer than the URL lifetime — large files, queued files, slow networks, sleeping devices.
  • You see 403s in upload telemetry that disappear when users retry manually.

Prerequisites

  1. An API endpoint that can re-sign a URL for an existing key (and upload ID and part number, for multipart) after checking the user still owns it.
  2. Access to the storage error response body in the browser (the bucket’s CORS must allow the request so the body is readable).
  3. A retry helper with backoff (implementing exponential backoff for failed chunks).

Where expiry bites

Timeline of a multipart upload with eagerly signed part URLs expiring All 200 part URLs are signed at the start with a 15 minute lifetime. Parts 1 to 80 upload in the first 15 minutes. Part 81 onward fail with 403 because their URLs expired while waiting. Lazy signing requests each URL just before its part starts, so every URL is fresh. Eager signing: URLs expire while parts wait their turn 0 min 15 min 35 min all URLs expire eager parts 1–80 OK parts 81–200 → 403 lazy each part signed just before it starts — all 200 OK Lazy signing prevents most expiries; detect-and-re-sign handles the rest (sleep, slow single parts).
Sign when you are about to use a URL, not when the upload begins.

Implementation

export class ExpiredUrlError extends Error { constructor(public detail: string) { super("presigned URL expired"); } }
export class ForbiddenError extends Error { constructor(public detail: string) { super("forbidden"); } }

/** Classify a failed storage response. Reads the body only for 400/403. */
export async function classify(res: Response): Promise<Error> {
  if (res.status !== 403 && res.status !== 400) return new Error(`HTTP ${res.status}`);
  const body = await res.text().catch(() => "");
  const code = /<Code>([^<]+)<\/Code>/.exec(body)?.[1] ?? "";
  const message = /<Message>([^<]+)<\/Message>/.exec(body)?.[1] ?? "";
  // S3: AccessDenied + "Request has expired"; temporary creds: ExpiredToken; GCS: ExpiredToken / "expired"; R2: "Request has expired".
  if (code === "ExpiredToken" || /expired/i.test(message) || /expired/i.test(code)) return new ExpiredUrlError(`${code}: ${message}`);
  if (code === "RequestTimeTooSkewed") return new ExpiredUrlError(code);   // client clock off: a fresh server-signed URL fixes it
  return new ForbiddenError(`${code}: ${message}`);
}

type Signer = (signal: AbortSignal) => Promise<{ url: string; headers?: Record<string, string> }>;

/** PUT a body to a presigned URL, re-signing once per attempt if the URL has expired. */
export async function putWithFreshUrl(sign: Signer, body: Blob, signal: AbortSignal, maxResigns = 3): Promise<Response> {
  let { url, headers } = await sign(signal);
  for (let resigns = 0; ; resigns++) {
    const res = await fetch(url, { method: "PUT", body, headers, signal });
    if (res.ok) return res;
    const err = await classify(res);
    if (err instanceof ExpiredUrlError && resigns < maxResigns) {
      ({ url, headers } = await sign(signal));          // same key / uploadId / partNumber, fresh signature
      continue;
    }
    throw err;
  }
}

// Multipart part upload with lazy signing
export async function uploadPart(ctx: { key: string; uploadId: string }, partNumber: number, blob: Blob, signal: AbortSignal) {
  const sign: Signer = async (s) => {
    const r = await fetch(`/api/uploads/multipart/${encodeURIComponent(ctx.uploadId)}/${partNumber}?key=${encodeURIComponent(ctx.key)}`, { signal: s });
    if (!r.ok) throw new Error(`sign failed: ${r.status}`);
    return r.json();
  };
  const res = await putWithFreshUrl(sign, blob, signal);
  return { PartNumber: partNumber, ETag: res.headers.get("ETag")! };
}

Line-by-line on the decisions that matter

  • Read the body to classify. A 403 is not always an expiry. Wrong signature, missing permission, a bucket policy denial and an expired URL all return 403; only the error code and message distinguish them. Retrying a real permission error wastes requests and hides the bug.
  • Clock skew is handled like expiry. RequestTimeTooSkewed means the signature time and the server clock disagree by more than 15 minutes. Because the URL is signed by your server, not the browser, a re-sign fixes it unless your server’s clock is wrong — worth an alert.
  • Re-sign for the same target. The new URL must be for the same key, and for multipart the same upload ID and part number. Parts already uploaded under the old URLs remain valid; completing the upload with their ETags works as usual.
  • Retry only the failed request. Everything else in the upload is untouched. For single PUTs of whole files, the retry re-sends the file; that is the case lazy signing and chunking are meant to make rare.
  • Bounded re-signs. If a freshly signed URL expires immediately, something else is wrong — the signing credentials themselves are expiring, or the server clock is off. Three attempts then a clear error beats an infinite loop.
  • Lazy signing per part. Requesting the URL just before the part starts means its lifetime covers only that part’s transfer. The extra API call per part is cheap compared to the part itself; batch-sign the next few parts if API load matters.

Choosing URL lifetimes

Sizing presigned URL lifetimes for uploads A URL lifetime should exceed the slowest expected transfer of the object or part it covers, with margin. For an 8 MB part on a 1 Mbps worst-case uplink, that is about 64 seconds, so 15 minutes is ample. For a 2 GB single PUT on the same link it is over 4 hours, which is why large files should be chunked. Credentials used to sign cap the lifetime regardless of the requested expiry. Lifetime ≥ slowest transfer of what it covers covers at 1 Mbps sensible expiry 8 MB part ~1 minute 15 minutes 50 MB photo batch item ~7 minutes 30–60 minutes 2 GB single PUT ~4.5 hours chunk it instead Signing with temporary credentials caps the effective lifetime at the credentials' own expiry, so a "7 day" URL signed by a Lambda role may die within the hour.
Short lifetimes are safe when each URL covers a small, quick transfer.

Long lifetimes are tempting and wrong: a URL is a bearer credential, and one that stays valid for days is usable by anyone who sees it in a log or a browser extension. The combination that works is short lifetimes (minutes), small units of work per URL (a part or a modest file), lazy signing, and detect-and-re-sign for the leftovers. With temporary credentials — Lambda roles, ECS task roles, SSO sessions — the URL cannot outlive the credentials that signed it, whatever expiresIn says; sign with a role whose session comfortably exceeds your URL lifetime, or accept that re-signing will happen more often.

Laptops that sleep

Resuming after device sleep When the device wakes, in-flight requests fail with network errors. The client waits for the online event, asks the server for the upload's current state, requests fresh URLs for the remaining parts and continues. It does not reuse any URL signed before the sleep. After waking, assume every URL is stale wake / online list parts what arrived? sign remaining fresh, lazily continue Detect long gaps by comparing Date.now() before and after; a jump of minutes means the device slept.
Proactive re-signing after sleep avoids a burst of 403s on wake.

When a device sleeps mid-upload, open requests die and any URLs signed before the sleep are likely expired on wake. Rather than letting each part fail with a 403 and re-sign individually, detect the gap — a watchdog that sees Date.now() jump by minutes between ticks, as in detecting stalled uploads with a progress watchdog — then pause the queue, wait for connectivity, ask the server which parts arrived, discard all cached URLs and continue with lazy signing. The same routine handles network changes that invalidate connections.

Configuration gotchas

Every 403 is treated as expiry and retried forever. The classifier matches too broadly. Match specific codes and messages, cap re-signs, and surface ForbiddenError as a real failure.

The response body is empty, so classification fails. The bucket’s CORS rule does not match the failed request, so the browser hides the response. CORS must allow the method and headers for error responses too; S3 applies the matching rule to 403s when the preflight passed.

Re-signed URL still fails with SignatureDoesNotMatch. The browser sends a header that was not signed, or a different content type. That is not expiry; compare signed headers with the request, as in debugging CORS with curl preflight requests.

Expiry right after signing. The server signs with credentials that are about to expire. Refresh credentials on the server before they near expiry, or sign with a longer-lived role.

Verification

# Sign a URL that expires in 5 seconds, wait, then PUT: expect the expiry body.
URL=$(node -e 'import("./sign.js").then(async m=>console.log(await m.signPut("test/a.bin",5)))')
sleep 8
curl -s -X PUT "$URL" --data-binary @a.bin | grep -oE '<(Code|Message)>[^<]+'
# <Code>AccessDenied
# <Message>Request has expired

In the browser, set a 30-second part URL lifetime, throttle to “Slow 3G” and upload a 50 MB file: parts that exceed 30 seconds should re-sign once and succeed, with no user-visible error.

Frequently Asked Questions

Is it safe to re-sign on request?

Yes, if the endpoint checks that the requester owns the key and upload ID, just as the original signing did. Re-signing grants nothing new — only more time for the same object.

Should users see anything when a URL is re-signed?

No. Re-signing is routine maintenance that takes a fraction of a second. Log it for telemetry — a rising re-sign rate tells you lifetimes are too short or signing credentials are expiring early — but keep the interface showing normal progress.

Should the server extend expiry automatically?

Presigned URLs cannot be extended; a new signature is always a new URL. Lazy signing achieves the same effect without long-lived URLs.

What about resumable protocols like tus or GCS sessions?

They avoid per-request signatures: the session itself is the credential and lasts for days. Expiry handling there means detecting a 404 or 410 for the session and starting a new one.