Home / AWS

AWSHow-toAdvanced

ExpiredToken in long-running AWS jobs: STS session duration, the role chaining one-hour limit and credentials that refresh themselves

Batch jobs, migrations and CI pipelines die after an hour with ExpiredToken. Learn where session limits come from, why role chaining caps you at one hour, and how to make credentials refresh automatically.

Deepak Bhardwaj6 min read

Applies toAWS STS (AssumeRole, GetSessionToken), AWS CLI v2, AWS SDKs (boto3, Java v2, Go v2), IAM Identity Center, EC2/ECS/EKS credential providers

The goal

A job that runs for three hours (a large S3 copy, a database export, a Terraform apply, a data backfill) should not fail at minute 61 with:

An error occurred (ExpiredToken) when calling the PutObject operation: The security token included in the request is expired

or fail to start with:

An error occurred (ValidationError) when calling the AssumeRole operation: The requested DurationSeconds exceeds the 1 hour session limit for roles assumed by role chaining.

Two separate questions are behind both errors: how long a session can last, and whether anything renews it before it expires. Raising the duration is often the wrong fix. Automatic refresh is usually the right one.

Where the session limit comes from

How the credentials were obtained Maximum session
AssumeRole from IAM user long-term credentials Role's MaxSessionDuration (1–12 hours), default request 1 hour
AssumeRole using credentials that are themselves from a role (role chaining) 1 hour, regardless of MaxSessionDuration
AssumeRoleWithWebIdentity / AssumeRoleWithSAML Role's MaxSessionDuration (and, for SAML, the SessionDuration attribute)
EC2 instance profile, ECS task role, Lambda execution role, EKS Pod Identity / IRSA via SDK Managed by AWS and refreshed automatically by the SDK
IAM Identity Center permission set The permission set's session duration (1–12 hours)
GetSessionToken (IAM user, e.g. for MFA) Up to 36 hours

Role chaining is the trap. It isn't only about humans hopping between accounts. All of these count:

  • A CLI profile with source_profile pointing at another role profile (or at an SSO profile) and then role_arn for a second role.
  • An EC2 instance, ECS task or Lambda function calling AssumeRole into another account. The instance profile role itself isn't chained, but the second role it assumes is, so that session is capped at one hour.
  • A CI runner that uses OIDC to get a role and then assumes a deployment role in the target account.

Check what you're running as and when it expires:

aws sts get-caller-identity
# An arn:aws:sts::...:assumed-role/... ARN means role credentials; anything they assume next is chained

aws configure export-credentials --profile deploy --format env-no-export | grep -i expir

Option 1: let the SDK or CLI do the refresh (preferred)

When credentials come from a provider rather than static values, the SDK refreshes them before expiry. A one-hour chained session is fine if the job's client re-assumes the role every hour without you noticing.

Configure the chain in ~/.aws/config, not in environment variables:

[profile ci-base]
credential_source = Ec2InstanceMetadata
# or: credential_source = EcsContainer / Environment

[profile prod-deploy]
role_arn = arn:aws:iam::222233334444:role/prod-deploy
source_profile = ci-base
role_session_name = nightly-backfill
duration_seconds = 3600
AWS_PROFILE=prod-deploy aws s3 cp s3://src-bucket/ s3://dst-bucket/ --recursive

The CLI and the SDKs that read shared config (boto3, Java v2, Go v2, JavaScript v3) re-run the AssumeRole when the cached credentials are close to expiry. In Python, keep one long-lived client built from a session that uses the profile:

import boto3

session = boto3.Session(profile_name="prod-deploy")   # assume-role provider refreshes itself
s3 = session.client("s3")

for key in huge_key_list:          # can run for hours
    s3.copy({"Bucket": "src-bucket", "Key": key}, "dst-bucket", key)

What breaks refresh:

# Anti-pattern: static snapshot of credentials. Nothing can refresh these.
eval "$(aws configure export-credentials --profile prod-deploy --format env)"
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SESSION_TOKEN=...

Warning

Once AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN are set as environment variables, the environment provider takes priority over every other source in the default chain. Even code that would refresh correctly will use the static token and fail when it expires. Many CI "assume role" steps work this way. Clear those variables for long jobs, or use a step that writes a profile instead.

Code that calls AssumeRole itself

If you call sts.assume_role() directly and build a client from the result, you own the refresh. Either switch to the profile approach above, or use a refreshing provider. In Java v2 that's StsAssumeRoleCredentialsProvider, which refreshes in the background. In Go v2 it's stscreds.NewAssumeRoleProvider wrapped in aws.NewCredentialsCache.

Option 2: avoid the chain

If an hour is genuinely too short for a single credential (for example an external tool that only accepts static keys at start-up), remove a hop so the final role is assumed directly:

  • Federate CI directly into the target role (OIDC trust on the deployment role itself), not into a hub role that then assumes it.
  • On EC2/ECS, give the instance or task role the permissions it needs directly, or move the job into the target account, rather than chaining.
  • For humans, assign an IAM Identity Center permission set in the target account rather than switching role from a hub account.

Then raise the role's maximum duration and request it explicitly:

aws iam update-role --role-name prod-backfill --max-session-duration 21600   # 6 hours
aws iam get-role --role-name prod-backfill --query Role.MaxSessionDuration
[profile prod-backfill]
role_arn = arn:aws:iam::222233334444:role/prod-backfill
web_identity_token_file = /var/run/secrets/ci/token
duration_seconds = 21600

Tip

Longer sessions mean that leaked credentials stay valid for longer. Raise MaxSessionDuration only on the specific roles that need it, and pair it with tight permissions and a trust policy conditioned on the expected source (aws:SourceIdentity, sts:RoleSessionName patterns or OIDC sub claims).

Things that outlive their credentials

Some artefacts are only valid while the credentials that created them are valid:

  • Presigned URLs stop working when the signing session expires, even if --expires-in was longer. Sign long-lived URLs from a role session that lasts at least as long, or from a refreshing service role.
  • Multipart uploads are signed per part, so a refreshing client can finish a multi-hour upload. A static token can't.
  • Kubernetes aws eks get-token tokens are short-lived by design. kubectl calls the exec plugin again when the token expires, so make sure the plugin's profile can refresh.

Revoking long sessions in an incident

Session tokens can't be deleted. To cut off sessions for a role that have already been issued, attach a deny based on issue time. The console's Revoke active sessions action does exactly this:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Deny",
    "Action": "*",
    "Resource": "*",
    "Condition": {"DateLessThan": {"aws:TokenIssueTime": "2026-11-09T14:30:00Z"}}
  }]
}
aws iam put-role-policy --role-name prod-backfill --policy-name AWSRevokeOlderSessions \
  --policy-document file://revoke.json

Refreshing clients recover on their own, because they get a new token issued after the cutoff. Clients holding static tokens fail, which is the point.

Prevent it next time

  • Ban static exported session credentials in long-running jobs. Use profiles with role_arn and credential_source/source_profile.
  • Map your role chains. Any hop after the first is capped at one hour.
  • Log the credential expiry at job start (aws configure export-credentials shows Expiration) so a too-short session shows up immediately.
  • Raise MaxSessionDuration per role and only when a non-refreshing tool requires it.
  • Test long jobs with duration_seconds = 900 in staging. If they survive 15-minute sessions, refresh is working.