S3 Presigned URL Expired — Clock Skew and TTL
Support ticket: “Download worked yesterday, today it says AccessDenied.” The link is a week-old Slack paste of an S3 presigned URL. Your backend signed it with a 15-minute expiry because someone read a security blog once. The user opened the email six hours later. That is not clock skew — that is TTL meeting human behavior. The harder cases are when the URL is fresh and still fails.
How signature time works
A presigned URL embeds credentials, expiry, and a signature over the canonical request. For SigV4, the important pieces in the query string are X-Amz-Date, X-Amz-Expires, and X-Amz-Signature. Validity is roughly:
not_before ≈ X-Amz-Date
not_after ≈ X-Amz-Date + X-Amz-Expires
If the signing host’s clock is five minutes ahead of AWS, short TTLs (60–120s) expire before the client ever GETs the object. If the client device clock is wildly wrong, browsers still send the request; S3 evaluates against AWS time, not the laptop. Clock skew hurts the signer, not the downloader’s wall clock display.
Failure signatures in the wild
| Symptom | Likely cause |
|---|---|
Request has expired / AccessDenied on old links | TTL shorter than user delay (email, chat, “save for later”) |
| Fresh URL fails immediately | Signer clock skew, wrong region endpoint, or IAM creds already rotated |
| Works in curl from bastion, fails in browser | Mixed content, ad blocker, or CORS on the bucket — not expiry |
| Works for PUT, fails for GET | Different signed method / headers than the client sends |
| Intermittent expiry | Load-balanced signers with unsynced clocks |
Signing checklist that prevents false “expired”
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
const client = new S3Client({ region: "ap-northeast-1" });
export async function downloadUrl(key: string, ttlSeconds = 900) {
return getSignedUrl(
client,
new GetObjectCommand({ Bucket: process.env.BUCKET, Key: key }),
{ expiresIn: ttlSeconds },
);
}
- Match region to the bucket. Signing against
us-east-1for anap-northeast-1bucket produces confusing auth errors that look like expiry. - Cap
expiresInby credential type. IAM role sessions and STS tokens cannot outlive the underlying session; asking for 7 days with a 1-hour role yields failures near the session end. - Do not put mutable headers in the signature unless the client will send them identically (
Content-Typeon PUT is a frequent footgun). - Prefer generating URLs at click time for downloads instead of embedding them in emails when the object is private.
UX: short TTL without angry users
Security wants minutes. Users want days. Patterns that reconcile both:
- Opaque download token in your app (
/api/files/:id/download) that checks auth and mint a fresh 60–120s presign on each click. - Separate TTLs by surface: 5 minutes for browser upload PUT, 24 hours only if you must email (and accept the risk).
- Show remaining lifetime in admin tools when debugging: decode
X-Amz-Date+X-Amz-Expiresand print UTC expiry next to the link. - Never log full presigned URLs to shared chat — treat them as bearer tokens for the object.
Clock skew remediation
On the signer:
timedatectl status # Linux
# ensure chrony/ntpd is happy; container images often drift without it
In AWS, Lambda and common managed runtimes are usually fine. Self-managed EC2/Docker build agents and laptop-based “quick scripts” are not. If you sign in CI to upload fixtures, sync time before the job or use longer TTLs for that path only.
Quick debug ritual
- Paste the URL query into a note; record
X-Amz-DateandX-Amz-Expires. - Compute expiry in UTC; compare to “now” on time.is (AWS side), not your local GUI clock alone.
- Retry with a newly signed URL from the same code path.
- If new URL works, the old one expired or credentials rotated — not a bucket ACL mystery.
- If new URL fails, dump the exact method/headers the client sends versus what was signed.
Presigned URLs are capability tokens with a fuse. Size the fuse for the channel (click vs email), keep signer clocks honest, and mint late. Most “S3 randomly expired” reports are one of those three.