This guide is for you if you hand out S3 presigned URLs and they start returning 403 Forbidden, the HTTP response that means access denied. A presigned URL is a temporary web link to one file in Amazon S3 storage that carries its own permission to download or upload that file. The usual complaint is a link that dies a few hours after you asked for a week, and the expiry you set is rarely the cause. A URL signed by an IAM role (the temporary identity a Lambda function or EC2 server runs under) stops working the moment that role’s session ends. The short answer is to sign with credentials whose lifetime you control, or to generate each link on demand and let it live only minutes.
What’s happening
When you generate a presigned URL, nothing is registered with AWS. The SDK computes a SigV4 signature locally from four inputs: the credentials, the exact request (bucket, key, method, signed headers), the region, and the expiry window (X-Amz-Expires). When someone uses the URL, S3 recomputes the signature and checks three things: the signature matches, the current time is inside the window, and the signing credentials would be allowed to make this request right now.
Each check has its own failure mode. The window is capped at 7 days (604800 seconds) with SigV4 — requests for more fail at generation time. The signature check fails if anything about the request differs from what was signed: a different Content-Type on a PUT that signed one, a proxy adding headers that were part of the signature, or a region mismatch. And the live-permission check means a presigned URL is revoked implicitly when the signer’s permissions are — or when the signer’s session ends.
That last one is the rotation trap. An EC2 instance profile delivers temporary credentials that rotate on a schedule of hours; Lambda and ECS task credentials behave the same way. A URL signed with those credentials includes the session token, and when the session expires — typically somewhere between one and twelve hours later, not under your control — every URL it signed returns 403 with ExpiredToken or AccessDenied, even if X-Amz-Expires said 7 days.
What to check first
- Read the actual error body.
AccessDenied: Request has expiredmeans the window;ExpiredTokenmeans the signing session;SignatureDoesNotMatchmeans the request differs from what was signed. The XML body tells you which — do not debug blind. - What signed the URL — an IAM user’s long-lived keys, or a role’s temporary session?
- How long after generation did it fail? Failures clustered hours after generation, before the requested expiry, point at session expiry.
- Did the client modify the request — added headers, changed content type, followed a redirect to another region?
How to verify
Generate a known-good URL and inspect its parameters:
# Simple GET presign from the CLI
aws s3 presign s3://my-bucket/report.pdf --expires-in 3600
Decode what a failing URL claims: X-Amz-Date (when signed), X-Amz-Expires (window in seconds), X-Amz-Credential (who signed, which region/date scope), and — the tell — X-Amz-Security-Token present means temporary credentials signed it. Then look at the actual failure:
# The XML error body is the diagnosis; -i shows the status
curl -si "https://my-bucket.s3.ca-central-1.amazonaws.com/report.pdf?X-Amz-..." | head -30
Check what identity your generation path actually runs under:
aws sts get-caller-identity
# On EC2: does the instance role session still have hours left?
aws sts get-session-token 2>/dev/null || echo "role credentials, rotation applies"
CloudTrail data events (if enabled on the bucket) show the failed GetObject with the error code — useful when the failing client is a customer’s browser you cannot instrument.
The runbook
- Match the expiry to the signer. If URLs must live longer than an hour or two, sign them with credentials whose lifetime you control: an IAM user dedicated to presigning (long-lived keys, tightly scoped policy), or accept the shorter horizon and regenerate. For role-signed URLs, treat the practical ceiling as the remaining session lifetime, not 7 days.
- Generate on demand, not in advance. The pattern that eliminates the whole class: the client asks your API for a URL at the moment of use, gets one valid for minutes, and uses it immediately. Short expiry becomes a security feature instead of a bug.
- For PUT uploads, sign what the client will send. If you sign
Content-Type: image/jpeg, the browser must send exactly that. Either pin it in the client code or leave the content type out of the signature (SDK-dependent) — mismatches produceSignatureDoesNotMatch. - Pin the region. Signing for
us-east-1and requesting against aca-central-1bucket endpoint fails. Configure the SDK client with the bucket’s real region; do not rely on redirects. - Remember KMS. For SSE-KMS objects, the signer needs
kms:Decrypt(GET) orkms:GenerateDataKey(PUT) at use time. A signer that loses key access invalidates its outstanding URLs. - Check clock skew for near-instant 403s. URLs that fail immediately after generation with expiry-style errors usually mean the generating host’s clock is minutes off — fix NTP.
When this is the wrong move
Presigned URLs are per-object, single-operation grants. If you are generating thousands per page load, or trying to grant browsing of a whole prefix, you want CloudFront signed cookies, an application proxy, or S3 Access Points instead. And a presigned URL is a bearer token — anyone holding it has the access, so URLs in email or long-lived links deserve a real authenticated download endpoint, not a 7-day presign.
Common mistakes
- Requesting
--expires-in 604800from a Lambda and expecting the URL to survive the week — the function’s session dies first. - Treating
SignatureDoesNotMatchas an expiry problem and regenerating in a loop, when the client is sending a header that was not signed. - Embedding presigned URLs in cached API responses or CDN-cached pages, so clients receive URLs that expired in cache.
- Rotating the presigning IAM user’s access keys and deleting the old key immediately — every outstanding URL signed with it dies at that moment; overlap the keys for the longest expiry you issue.
Prevention
- Standardize one presigning path in your codebase — one function, one identity, one expiry policy — instead of scattered SDK calls.
- Log
X-Amz-Credentialscope with each generated URL so a 403 report can be traced to signer and signing time in seconds. - Alert on spikes of 403s on presigned paths; they usually mark a rotation event or a deploy that changed the signing identity.
- Keep expiries as short as the workflow tolerates — minutes for interactive flows — and document the SigV4 behavior for the team from the source at https://docs.aws.amazon.com/AmazonS3/ .
Once you internalize that a presigned URL is only as durable as its signer’s credentials, the 403s stop being mysterious and start being schedulable. If nobody on your team truly owns the AWS environment end to end, that is the root of most of these problems — Stack Harbor runs AWS accounts daily under AWS Operations, and you can book an AWS review.