# Why S3 presigned URLs expire early

Canonical: https://rendobar.com/blog/s3-presigned-url-expires-early/
Author: Abdelrahman Essawy
Published: 2026-09-09
Updated: 2026-09-13

---

## Key takeaways

- A presigned URL can't outlive the credentials that signed it. AWS says a URL signed with role credentials expires when the role session expires, even if you specify a longer expiration time.
- We signed a 1-hour URL with a 15-minute role session. A minute after the session ended it failed with 44 minutes of its hour still to run, and a real job reading it failed to fetch its input.
- The failure was HTTP 400 Bad Request with the code ExpiredToken, not a 403, so an alert or retry rule that only watches for 403 misses it.
- An AssumeRole session lasts 1 hour by default and at most 12, role chaining caps it at 1 hour, and EC2 instance profile credentials typically last about 6. Only a long-term IAM user key reaches the 7-day maximum.
- The fix is to size the session to the link: request a longer DurationSeconds, raise the role's MaxSessionDuration to match, and cap ExpiresIn at the time the session has left.

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.

_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."

_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 signing | 1 minute after the session ended |
|---|---|---|
| Time since signing | 1 s | 960 s |
| Left of the URL's own 1-hour expiry | 60 min | **44 min** |
| Direct request | `206 Partial Content` | **`400 Bad Request`**, code `ExpiredToken` |
| Job reading the URL | completed | failed, `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.

## How to make the link last

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

```ts
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:

```bash
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.

## How a job's input link stays valid

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](/docs/storage/amazon-s3) 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](/blog/s3-access-without-access-keys/). For what a long
job does with its input, see [processing video from an S3
bucket](/blog/process-s3-video-ffmpeg/).
