Why S3 presigned URLs expire early

Read why an S3 presigned URL stops working before its expiry time. Signed with a 1-hour expiry by a 15-minute role session, ours failed once the session ended.

Share

You sign an S3 URL for a day, hand it to a job or a customer, and an hour later it stops working. The expiry in the URL is still hours away, and nothing about the URL changed.

Short version. A presigned URL can’t outlive the credentials that signed it. When those credentials came from an assumed role, the URL dies with the role session, and a default session lasts 1 hour. We signed a 1-hour URL with a 15-minute session, and a minute after the session ended it failed with 44 minutes of its hour left. It didn’t fail with a 403 either. S3 answered 400 Bad Request with ExpiredToken. The fix is to sign with a session at least as long as the link, and never ask for an expiry longer than the session has left.

0:000:301:001:302:00role session endsRole sessionAssumeRole, default 1 hURL from the roleExpiresIn 604800403 ExpiredTokenURL from a user keyExpiresIn 604800runs to 7 daysDownload at 0:59checked when it startsfinishes past 1:00
The URL asked for 7 days. It got whatever was left of the session that signed it.

The rule, in AWS’s words

The Amazon S3 guide lists which credentials can sign a URL and how long each can make it last. For a long-term IAM user the answer is up to 7 days with Signature Version 4. For everything temporary, it is shorter, and it doesn’t depend on what you asked for:

  • IAM role credentials: “The presigned URL expires when the role session expires, even if you specify a longer expiration time.”
  • Role credentials on Amazon EC2: “Valid for the duration of the role credentials (typically 6 hours).”
  • AWS STS credentials: “Valid only for the duration of the temporary credentials.”

The same page’s FAQ adds the case that catches containers: “For Amazon Elastic Container Service tasks or containers, role credentials typically rotate every 1-6 hours.” And for the most common setup of all, “When using AWS Security Token Service (AWS STS) AssumeRole, the presigned URL expires when the role session ends, which by default is 1 hour.”

Horizontal bar chart on a log scale of the longest a presigned S3 URL can work by the credential that signed it. Role chaining: 1 hour. AssumeRole session default: 1 hour. EC2 instance profile credentials, typical: 6 hours. AssumeRole session at the role maximum: 12 hours. IAM user access key with Signature Version 4: 7 days.
The ceiling on a presigned URL is set by the credential that signed it. Only a long-term key reaches 7 days.

Measured: a 1-hour URL from a 15-minute session

To see the failure rather than take the documentation’s word for it, a script created a throwaway bucket with one object, and a role allowed only s3:GetObject on it. It assumed the role with DurationSeconds of 900, signed a GET URL with ExpiresIn of 3600, and used the URL twice: straight away, and again one minute after the session ended. Both times it also handed the URL to a real job on the Rendobar API, because the failure people actually meet is a pipeline that can’t fetch its input.

Right after signing1 minute after the session ended
Time since signing1 s960 s
Left of the URL’s own 1-hour expiry60 min44 min
Direct request206 Partial Content400 Bad Request, code ExpiredToken
Job reading the URLcompletedfailed, INPUT_FETCH_FAILED

The job’s error said what went wrong from its side of the wire: Failed to download input "source" with 400 Bad Request. Nothing in that message points at a role session, which is why this bug gets filed as a flaky download.

The status code is the detail worth keeping. The S3 guide’s FAQ answers “Why am I getting a 403 Forbidden error” with a permissions checklist, and treats ExpiredToken as a separate question without naming a status for it. On this run S3 returned 400, with the reason in the response body’s Code element. A retry policy, an alert or a log search keyed only on 403 would file this failure as something else. Match on ExpiredToken in the body, or on both statuses.

Where the short sessions come from

Most people who hit this never chose a 1-hour session. They got one by default.

AssumeRole without DurationSeconds. The IAM documentation gives every assume method the same shape: a minimum of 15 minutes, a maximum of the role’s own maximum session duration setting, and a default of 1 hour. Ask for nothing and you get an hour.

A role maximum left at 1 hour. The role’s maximum session duration “can have a value from 1 hour to 12 hours”. Request more than the role allows and the call fails, so code that works in development with a short session starts failing the moment someone raises DurationSeconds without touching the role.

Role chaining. Assuming a role from another role’s session caps the new session at 1 hour, whatever either role allows. This applies to console role switching, the CLI and the API alike.

Compute that rotates its own credentials. On EC2, ECS and similar services, the SDK picks up role credentials that the platform refreshes on its own schedule. A URL signed a few minutes before a rotation inherits the credentials that are about to expire.

Size the session to the link, then never promise more than the session has left.

import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const wantSeconds = 6 * 3600;
const { Credentials } = await new STSClient({}).send(
new AssumeRoleCommand({
RoleArn: process.env.LINK_SIGNER_ROLE_ARN,
RoleSessionName: "link-signer",
// Must not exceed the role's maximum session duration, or STS refuses the call.
DurationSeconds: wantSeconds + 300,
}),
);
if (!Credentials?.Expiration) throw new Error("STS returned no session");
const s3 = new S3Client({
credentials: {
accessKeyId: Credentials.AccessKeyId,
secretAccessKey: Credentials.SecretAccessKey,
sessionToken: Credentials.SessionToken,
},
});
// Never promise a link longer than the session behind it.
const secondsLeft = Math.floor((Credentials.Expiration.getTime() - Date.now()) / 1000);
const url = await getSignedUrl(s3, new GetObjectCommand({ Bucket: "my-bucket", Key: "raw/clip.mp4" }), {
expiresIn: Math.min(wantSeconds, secondsLeft),
});

The role has to allow the longer session first:

Terminal window
aws iam update-role --role-name link-signer --max-session-duration 43200

Three habits cover the rest. Sign a URL at the moment you hand it out rather than caching a batch of them, since a fresh URL starts with a fresh session. Don’t sign from a chained role if the link needs more than an hour. And if a link genuinely has to last days, that is the one case for a dedicated IAM user whose only permission is s3:GetObject on that prefix.

A job adds a wait before the URL is even used, since it can sit in a queue before it starts. Rendobar mints the link for a storage input when the job is dispatched, sized to last as long as the longest job any plan allows. For an Amazon S3 connection that uses a role, it asks AWS for a 12-hour session and caps the link at whatever that session has left, so a job is never handed a link whose credentials die first. The role the one-click setup creates allows 12-hour sessions for exactly this reason, and the Amazon S3 storage guide walks through it.

Where this stops

The measurement is one run: one object, one region, one deliberately short session. It shows the mechanism, and AWS’s documentation is the authority on the exact lifetimes. The 400 is what S3 returned on this run in eu-west-1. Treat both 400 and 403 as possible, and read the error code.

The 6-hour and 1-to-6-hour figures for EC2 and ECS are AWS’s “typical” values, not guarantees, so code should read the credentials’ own expiry rather than assume either.

Everything here is about GET links. Presigned PUT URLs for uploads follow the same rule, and a large multipart upload that outlasts its session fails on the parts it hasn’t sent yet.

For connecting a bucket through a role in the first place, see giving a service S3 access without an access key. For what a long job does with its input, see processing video from an S3 bucket.

Frequently asked questions

Why does my S3 presigned URL expire before its expiration time?

Because it was signed with temporary credentials that expired first. A URL signed by an assumed role, an EC2 instance profile or any other STS session stops working when that session ends, whatever expiry the URL requested. A default AssumeRole session lasts 1 hour.

What is the maximum expiration time for an S3 presigned URL?

7 days, with Signature Version 4 and a long-term IAM user's access key. The S3 console caps it at 12 hours. With temporary credentials the real maximum is whatever is left of the session, up to 12 hours for an assumed role.

What does ExpiredToken mean when opening a presigned URL?

The session token embedded in the URL has expired. The signature is still correct, but the credentials behind it are no longer valid. In our test S3 answered HTTP 400 Bad Request with the error code ExpiredToken, not a 403. Sign a new URL with fresh credentials.

How do I make a presigned URL last 12 hours with an IAM role?

Set the role's maximum session duration to 12 hours, assume it with DurationSeconds of up to 43,200, and sign the URL straight away with those credentials. Role chaining, assuming one role from another, still caps the session at 1 hour.

Does a download in progress stop when the presigned URL expires?

No. S3 checks the expiry when the request starts, so a download that began before the expiry continues past it. If the connection drops and the client retries after the expiry, the retry fails.

Sources

Tags #s3#presigned-url#aws-iam#storage#aws-sts
All posts
Share
  1. Video APIs that deliver output to your own bucket Guides for the video API
  2. Give a service S3 access without an access key Guides for the video API
  3. Add video processing to n8n Cloud Guides for the video API