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
- 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.
- Access to the storage error response body in the browser (the bucket’s CORS must allow the request so the body is readable).
- A retry helper with backoff (implementing exponential backoff for failed chunks).
Where expiry bites
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.
RequestTimeTooSkewedmeans 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
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
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.