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

  1. Access to the signing environment’s credential source (role, session duration settings).
  2. @aws-sdk/client-s3, @aws-sdk/s3-request-presigner and @aws-sdk/client-sts v3.
  3. The ability to inspect a failing URL’s query string — especially X-Amz-Date, X-Amz-Expires and X-Amz-Security-Token.

Two clocks on every URL

URL expiry versus credential expiry A URL signed at 10:00 with X-Amz-Expires of 24 hours would be valid until 10:00 the next day. But the signing session token expires at 10:40, and S3 rejects the URL after that. The effective lifetime is the shorter of the two: 40 minutes. Effective lifetime = min(URL expiry, credential expiry) X-Amz-Expires 86 400 s — until tomorrow 10:00 session token expires 10:40 what users get 40 minutes, then ExpiredToken 10:00 signed +24 h The session token travels inside the URL (X-Amz-Security-Token); S3 validates it on every request. A signature is only as long-lived as the key that made it.
The expiry you set is an upper bound; the credential behind the signature sets the real one.

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 exposes expiration for 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 effectiveExpiresAt lets the caller tell users the truth (“link valid for 38 minutes”) or decide to re-sign later. An API that returns expiresIn: 86400 and 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_800 cap. 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 MaxSessionDuration allows 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

Session duration limits with and without role chaining IAM user keys assuming a role can request up to the role's maximum session duration, up to 12 hours. A Lambda function, already running as a role session, assuming another role is role chaining, which AWS caps at one hour whatever the target role allows. Who assumes the role decides the ceiling IAM user keys AssumeRole MaxSession 12 h up to 12 h Lambda role (already a session) AssumeRole MaxSession 12 h capped at 1 h role chaining Requesting DurationSeconds above 3600 in a chained call fails with a ValidationError.
From inside AWS compute you are almost always chaining roles, so one hour is the practical ceiling for STS-signed URLs.

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.

Options for URLs that must outlive an hour Re-signing at the moment of use through a redirect endpoint keeps URLs short. A dedicated signer with long-term scoped keys can issue URLs up to seven days. CloudFront signed URLs use a key pair and can have any expiry. Each trades convenience against credential exposure. Need a link that lives longer than the session? re-sign on use stable app link → fresh 5-min URL best default long-term signer scoped IAM user keys up to 7 days rotate and restrict hard CloudFront signing key-pair signature any expiry, cached reads only Longer-lived links are more valuable if leaked — prefer the left box whenever the client can come back to you.
Most "long URL" requirements disappear once links point at your app instead of at storage.

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.