Why Presigned URLs Expire Early with Temporary Credentials
A presigned URL is valid until the earlier of its X-Amz-Expires time and the expiry of the temporary credentials that signed it, because S3 checks the session token embedded in the URL on every request; so a URL signed with expiresIn: 86400 by a Lambda function whose role session ends in 40 minutes stops working in 40 minutes — fix it by signing shortly before use, signing with a longer-lived session when you truly need long URLs, or using a CloudFront signed URL whose key pair does not expire.
This produces one of the most confusing upload bugs in production: URLs that work in testing and fail for real users with ExpiredToken or AccessDenied: Request has expired, at seemingly random times well inside the configured expiry. The cause is not in the URL code at all but in where the signing credentials came from. This page belongs to S3 presigned URL workflows in backend validation and cloud storage architecture. The URL generation itself is covered in generating secure presigned URLs with AWS SDK v3.
When to use this approach
- Presigned URLs fail before their stated expiry, especially for large or slow uploads and downloads.
- Your signing code runs on Lambda, ECS/Fargate, EKS with IRSA, EC2 instance roles, or a developer’s SSO session — anything using temporary credentials.
- You need URLs valid for hours or days (email links, background uploads) and want to know what is actually possible.
Prerequisites
- Access to the signing environment’s credential source (role, session duration settings).
@aws-sdk/client-s3,@aws-sdk/s3-request-presignerand@aws-sdk/client-stsv3.- The ability to inspect a failing URL’s query string — especially
X-Amz-Date,X-Amz-ExpiresandX-Amz-Security-Token.
Two clocks on every URL
Where signing credentials come from, and how long they live
| Signing environment | Credential type | Typical lifetime | Longest URL you can rely on |
|---|---|---|---|
| IAM user access keys | long-term | until rotated | 7 days (SigV4 maximum) |
| Lambda execution role | STS session | ~15 min–12 h, refreshed by the runtime | remaining session time at signing |
| ECS/Fargate task role | STS session | ~6 h, rotated before expiry | remaining session time at signing |
| EKS IRSA / Pod Identity | STS session | 1 h default (web identity) | ≤ 1 h |
| EC2 instance profile | STS session | ~6 h, rotated | remaining session time at signing |
| AssumeRole in your code | STS session | 15 min–role max (1–12 h) | the DurationSeconds you requested |
| Role chaining (role→role) | STS session | capped at 1 h | ≤ 1 h |
| Developer SSO session | STS session | the SSO permission set duration | remaining session time |
The awkward case is the managed runtimes: Lambda, ECS and EC2 rotate credentials automatically before they expire, so the credential in memory at the moment you sign may have anywhere from a few minutes to several hours left. Identical code produces URLs with wildly different effective lifetimes depending on when in the rotation cycle it ran.
Implementation
A signer that knows how long its credentials have left, and either caps the URL honestly or refreshes first:
import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { fromNodeProviderChain } from "@aws-sdk/credential-providers";
import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";
const provider = fromNodeProviderChain();
const s3 = new S3Client({ credentials: provider });
/** Seconds left on the credentials the SDK would sign with right now. */
async function credentialSecondsLeft(): Promise<number> {
const creds = await provider();
if (!creds.expiration) return Number.POSITIVE_INFINITY; // long-term keys
return Math.floor((creds.expiration.getTime() - Date.now()) / 1000);
}
export interface SignedUrl { url: string; effectiveExpiresAt: Date; capped: boolean }
/** Sign with an honest expiry: never promise more than the credentials can deliver. */
export async function signHonestly(cmd: GetObjectCommand | PutObjectCommand, wantSeconds: number): Promise<SignedUrl> {
const left = await credentialSecondsLeft();
const margin = 60; // clock skew between you and S3
const seconds = Math.max(60, Math.min(wantSeconds, left - margin, 604_800));
const url = await getSignedUrl(s3, cmd, { expiresIn: seconds });
return { url, effectiveExpiresAt: new Date(Date.now() + seconds * 1000), capped: seconds < wantSeconds };
}
/** For genuinely long URLs: sign with a dedicated role session of known duration. */
export async function signWithLongSession(cmd: GetObjectCommand | PutObjectCommand, seconds: number): Promise<string> {
const sts = new STSClient({});
const { Credentials: c } = await sts.send(new AssumeRoleCommand({
RoleArn: process.env.LONG_URL_SIGNER_ROLE_ARN!, // role MaxSessionDuration set to 12 h
RoleSessionName: "long-url-signer",
DurationSeconds: Math.min(seconds + 300, 43_200),
}));
const longS3 = new S3Client({ credentials: {
accessKeyId: c!.AccessKeyId!, secretAccessKey: c!.SecretAccessKey!, sessionToken: c!.SessionToken!,
expiration: c!.Expiration } });
return getSignedUrl(longS3, cmd, { expiresIn: Math.min(seconds, 43_200) });
}
// Usage
const r = await signHonestly(new GetObjectCommand({ Bucket: "media", Key: "exports/9c1f.zip" }), 86_400);
console.log(r.capped ? `capped: valid until ${r.effectiveExpiresAt.toISOString()}` : "full lifetime");
Line-by-line on the decisions that matter
credentialSecondsLeft(). The credential provider exposesexpirationfor temporary credentials. Reading it before signing turns a silent failure into an explicit decision: cap the URL, refresh, or use another signer.- Capping instead of lying. Returning
effectiveExpiresAtlets the caller tell users the truth (“link valid for 38 minutes”) or decide to re-sign later. An API that returnsexpiresIn: 86400and a URL that dies in 38 minutes is the bug this page is about. - The 60-second margin. S3 compares times with its own clock. A URL that expires within seconds of the session can fail on a server whose clock is slightly ahead.
604_800cap. SigV4 presigned URLs cannot exceed seven days, even with long-term credentials.- A dedicated long-session role. When you do need long URLs (a 12-hour download link), assume a role whose
MaxSessionDurationallows it, with permissions limited to exactly what the URLs grant. Note that role chaining — assuming a role from a role session, which is what Lambda does — caps sessions at one hour regardless of the role’s maximum.
Why role chaining caps you at one hour
Designing around short-lived signatures
The robust answer for most products is not longer URLs but URLs issued at the moment of use. Instead of emailing a presigned download link, email a link to your application (/files/9c1f/download) that authorises the user and redirects to a fresh, minutes-long presigned URL. Instead of issuing all multipart part URLs up front, sign them in batches as the upload progresses, as in presigning S3 multipart upload parts. Instead of giving a background uploader a URL valid for a day, give it a way to request new URLs when needed.
When a long-lived URL is genuinely required — a partner system that fetches files hours later and cannot call your API — sign it outside the chained-role path: from a service whose credentials are long-term keys stored in a secrets manager and rotated, scoped to read-only access on one prefix, or put CloudFront in front and use CloudFront signed URLs, which are signed with a key pair you control and can have any expiry you choose.
Configuration gotchas
ExpiredToken: The provided token has expired. The session token in the URL is past its expiry. Not a clock problem; the credentials were short-lived. Check where the signer got its credentials and when.
AccessDenied: Request has expired with X-Amz-Expires far in the future. Either the signing credentials expired, or the signer’s clock was wrong when it computed X-Amz-Date. Compare X-Amz-Date with the time you signed.
ValidationError: The requested DurationSeconds exceeds the 1 hour session limit for roles assumed by role chaining. You tried to assume a role for longer than an hour from a role session. Use a non-chained signer or accept the one-hour ceiling.
URLs fail only for some users, only sometimes. Signing happens at different points in the runtime’s credential rotation cycle. Log credentialSecondsLeft() next to every signed URL for a day; the pattern becomes obvious.
Verification
# Inspect a failing URL: when was it signed, and with what expiry?
python3 -c "import sys,urllib.parse as u; q=u.parse_qs(u.urlparse(sys.argv[1]).query); print(q['X-Amz-Date'][0], q['X-Amz-Expires'][0], 'token' if 'X-Amz-Security-Token' in q else 'no-token')" "$URL"
# 20260918T100012Z 86400 token ← a session-token URL: lifetime capped by the session
# From the signing environment, how long do the current credentials have?
aws sts get-caller-identity && aws configure export-credentials --format env | grep EXPIRATION
Frequently Asked Questions
Why did this work in development?
Local development often signs with long-term IAM user keys or a fresh SSO session, which live far longer than the credentials a Lambda or container has at a random moment. The same code behaves differently with a different credential source.
Can I extend a session token?
No. Temporary credentials cannot be extended; you can only obtain new ones. URLs signed with the old credentials stay bound to the old expiry.
Do GCS and Azure have the same problem?
GCS V4 signed URLs made through keyless signing (IAM signBlob) are not tied to a short-lived token — they are valid until their own expiry, up to seven days. Azure user delegation SAS tokens are capped by the delegation key’s expiry, the same shape of problem as here.