Core engineering · Practical technical guide · 26 October 2026
Why a batch job, export or migration that runs for hours fails with ExpiredToken at an awkward moment, how AssumeRole session duration and the one-hour role-chaining limit work, why presigned URLs die with the credentials that signed them, and how to structure clients so credentials refresh.
The failure in the middle of the night
A data export starts at 01:00, runs for three hours and dies at 02:00 with ExpiredToken. A migration tool that worked in testing fails in production two hours in. A link in a report email, presigned for seven days, stops working the same afternoon. Each of these is the same mistake: temporary credentials have a lifetime, and something in the job took a snapshot of them instead of asking for fresh ones.
Temporary credentials from AWS STS are an access key, a secret key and a session token with an expiry. The SDKs refresh them automatically when they come from a provider that can be asked again: an assume-role profile, web identity, the container or instance metadata endpoints, or IAM Identity Center. They cannot refresh credentials that were handed to them as fixed values, such as keys passed into a client constructor or exported as environment variables by a wrapper script. A quick check with botocore 1.43.116 shows the difference: a Boto3 session built from explicit keys returns a plain Credentials object, which has no way to refresh itself.
How long a session can last
| How the credentials were obtained | Lifetime per AWS documentation |
|---|---|
| AssumeRole with DurationSeconds | 900 seconds up to the role maximum session duration; default 3,600 seconds |
| Role maximum session duration | A role setting from 1 hour to 12 hours |
| Role chaining: assuming a role with credentials from another role | Limited to one hour; DurationSeconds above one hour makes AssumeRole fail |
| GetSessionToken for an IAM user | Default 12 hours, from 15 minutes up to 36 hours; one hour for the root user |
| Presigned URL signed with temporary credentials | Expires when the credentials expire, even if a longer expiry was requested |
Role chaining catches many teams. A CI runner that assumes a deployment role, which then assumes a role in a target account, is chaining, and the second session is capped at one hour whatever the target role's maximum says. The IAM documentation applies the limit to console role switching, the CLI and the API alike.
Presigned URLs inherit the expiry of their signer
A presigned URL is not a separate credential: it is a request signed with your credentials, and S3 validates it against them when it is used. The S3 documentation is explicit that a URL created with temporary credentials expires when the credentials expire, even if you specified a longer expiration, and that a URL can be valid for up to seven days only when signed with long-term IAM user credentials. Its troubleshooting notes add typical lifetimes: ECS task role credentials rotate every one to six hours, instance profile credentials have a maximum validity of about six hours, and an AssumeRole session lasts one hour by default.
So a report job running on ECS that presigns a seven-day link produces a link that may die within the hour. If links must outlive the job, sign them with a dedicated identity whose credentials last at least as long as the link, or serve downloads through an endpoint that checks your own authorization and presigns on demand.
A worked example: a three-hour export
An export job on an EC2 instance assumes a role in the data account with a 12-hour maximum session, using credentials it got from the instance profile, then writes Parquet files to S3 for three hours. The first version reads the instance credentials, calls AssumeRole once, and builds the S3 client with the returned keys. It fails at about 60 minutes, for two reasons: the AssumeRole call defaulted to 3,600 seconds, and because the source credentials came from a role, this is role chaining, so asking for longer would have failed anyway.
Illustrative Python; prefer a profile with role_arn and credential_source in ~/.aws/config, which gives the same refresh without private attributes.
import boto3
from botocore.credentials import DeferredRefreshableCredentials
from botocore.session import get_session
ROLE = "arn:aws:iam::111122223333:role/export-writer"
def assume():
creds = boto3.client("sts").assume_role(
RoleArn=ROLE, RoleSessionName="nightly-export",
DurationSeconds=3600)["Credentials"] # chained: one hour is the cap
return {"access_key": creds["AccessKeyId"],
"secret_key": creds["SecretAccessKey"],
"token": creds["SessionToken"],
"expiry_time": creds["Expiration"].isoformat()}
def refreshing_session():
core = get_session()
core._credentials = DeferredRefreshableCredentials(
refresh_using=assume, method="sts-assume-role")
return boto3.Session(botocore_session=core)
s3 = refreshing_session().client("s3") # refreshes before expiry, for hoursThe cleaner fix needs no code. A profile in ~/.aws/config with role_arn and credential_source set to Ec2InstanceMetadata makes Boto3 call AssumeRole for you; its documentation says it caches the temporary credentials in memory and refreshes them automatically when they expire. The session still lasts at most an hour, but the client fetches a new one before it ends, so the job can run for as long as it needs. The snippet shows the same mechanism when you cannot use a profile; it relies on a private attribute and should be a last resort.
Structuring long jobs
- Create clients from a provider that can refresh: an assume-role profile, web identity, container or instance credentials. Never construct clients from copied keys in a long job.
- Avoid role chaining where the job runs long; grant the needed permissions to the first role, or let the platform assume the target role directly.
- Set DurationSeconds deliberately and the role maximum session duration to match, knowing that refresh, not duration, is what makes long jobs safe.
- Make the job resumable by checkpointing progress, so a credential failure costs minutes, not the whole run.
- Sign presigned URLs with credentials that outlive the link, or presign at download time.
Failure modes
- Wrapper scripts that export keys. A shell step that runs aws sts assume-role and exports the three variables gives every later process fixed credentials with a one-hour clock.
- Clients created once at start-up in a worker pool. If they were built from fixed keys, every worker fails at the same moment.
- Multipart uploads that span the expiry. Parts signed after expiry fail; with a refreshing client, later parts are signed with new credentials.
- Clock skew. A host clock far off makes valid credentials look expired or not yet valid; keep time synchronised.
Verification
In a test account, set the role maximum session duration to one hour, run the job for longer than an hour with a refreshing client, and confirm in CloudTrail that AssumeRole was called again before the first session expired. Presign a URL from a role session with a seven-day expiry and confirm that it fails after the session ends. If your jobs all finish well within an hour, or run on a platform whose SDK credentials refresh by default and you never copy keys, none of this needs changing, but the presigned-URL rule still applies.
Sources and scope
Checked against these sources on 26 October 2026. Evergreen guide based on AWS and Boto3 documentation read on this date; the export example is illustrative.
- AWS STS API: AssumeRole (DurationSeconds)
- AWS IAM: Methods to assume a role (role chaining limited to one hour)
- AWS IAM: Request temporary security credentials
- Amazon S3: Download and upload objects with presigned URLs (expiry and temporary credentials)
- Boto3 documentation: Credentials (provider chain, assume-role refresh)
Build long-running clients from credential providers that refresh, avoid role chaining where jobs run for hours because it caps sessions at one hour, never pass copied keys into long jobs, and sign presigned URLs with credentials that outlive the link.