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_profilepointing at another role profile (or at an SSO profile) and thenrole_arnfor a second role. - An EC2 instance, ECS task or Lambda function calling
AssumeRoleinto 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_KEYandAWS_SESSION_TOKENare 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
MaxSessionDurationonly 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:RoleSessionNamepatterns or OIDCsubclaims).
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-inwas 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-tokentokens are short-lived by design.kubectlcalls 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_arnandcredential_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-credentialsshowsExpiration) so a too-short session shows up immediately. - Raise
MaxSessionDurationper role and only when a non-refreshing tool requires it. - Test long jobs with
duration_seconds = 900in staging. If they survive 15-minute sessions, refresh is working.