Short-Lived Credentials — STS, Dynamic Secrets, and Why Static Keys Die
Static keys leak and live forever. Short-lived credentials from STS and Vault expire on their own — here's the token-exchange machinery and the TTL math that make it work.
Key takeaways
- Static keys leak and live forever.
- Short-lived credentials from STS and Vault expire on their own — here's the token-exchange machinery and the TTL math that make it work.
On this page
Short-Lived Credentials — STS, Dynamic Secrets, and Why Static Keys Die
A static access key has no idea it's been stolen. It works for the attacker exactly as well as it works for you, and it keeps working until a human notices and revokes it. That's the whole problem in one sentence. The fix isn't better rotation — it's credentials that expire on their own, minted the moment you need them and dead an hour later. Once you internalize that, most of the secrets-in-CI, keys-in-git, and token-in-Slack incidents stop being possible.
This is the machinery underneath keyless authentication: the token exchange that hands you temporary credentials, and the lease model that reclaims them.
Token exchange: you prove identity, you get a short ticket#
The pattern is always the same. You show up holding something that proves who you are — an OIDC token from your CI provider, a signed instance identity, an existing session — and a broker hands back a fresh set of credentials with an expiry stamped on them. You never store the result. When it dies, you exchange again.
AWS STS is the canonical broker. AssumeRole takes an existing AWS identity and returns temporary creds for a role. AssumeRoleWithWebIdentity takes an OIDC token from outside AWS — GitHub Actions, a Kubernetes service account, GCP — and does the same with no stored AWS key anywhere in the chain.
aws sts assume-role-with-web-identity \
--role-arn arn:aws:iam::123456789012:role/ci-deploy \
--role-session-name gha-build-4192 \
--web-identity-token "$OIDC_TOKEN" \
--duration-seconds 3600
What comes back is the important part:
{
"Credentials": {
"AccessKeyId": "ASIA...",
"SecretAccessKey": "...",
"SessionToken": "...",
"Expiration": "2026-07-12T15:04:07Z"
}
}
Three things instead of two. The SessionToken is what marks these as temporary — a request without it is rejected, and it's baked with the same expiry. The ASIA prefix (versus AKIA for static keys) is a giveaway you can grep for in logs and CloudTrail to confirm nobody's using long-lived keys where they shouldn't.
Session duration is a real dial, and you should turn it down#
--duration-seconds defaults to one hour and caps at the role's MaxSessionDuration (up to 12 hours). Role chaining — assuming a role from an already-assumed session — hard-caps at one hour regardless of what you ask for, which trips people up constantly.
The instinct is to crank duration up so nothing expires mid-job. Resist it. A shorter session is a smaller blast radius. If a token leaks, the attacker's window is measured against how long it had left, not against how long until someone rotates a key. For CI I set 15–30 minutes for most jobs. If a deploy legitimately runs longer than an hour, that's usually a signal the job should be split, not that the session should live half a day.
Vault dynamic secrets: the same idea for databases#
STS handles AWS. For everything with a username and password — Postgres, MySQL, RabbitMQ, cloud APIs — HashiCorp Vault does the equivalent with dynamic secrets. Instead of storing a database password, the app asks Vault at request time and Vault creates a brand-new database user, scoped to a role, with a lease and a TTL. When the lease expires (or you revoke it), Vault runs the revocation SQL and the user is gone.
# connection: Vault holds ONE privileged cred, apps never see it
resource "vault_database_secret_backend_role" "app_ro" {
backend = "database"
name = "app-ro"
db_name = "app-postgres"
creation_statements = [
"CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';",
"GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";"
]
revocation_statements = ["DROP ROLE IF EXISTS \"{{name}}\";"]
default_ttl = "1h"
max_ttl = "24h"
}
Reading a credential is one call:
$ vault read database/creds/app-ro
Key Value
lease_id database/creds/app-ro/8Yx...
lease_duration 1h
username v-token-app-ro-x7Qp2f9k
password A1b-c3D...
Every app instance gets a distinct username. That alone changes incident response — a leaked credential is traceable to exactly one workload and one lease, and revoking it touches nothing else.
Why TTL plus revocation beats a rotation schedule#
Rotation is a batch job that pretends to be security. Between rotations, the credential is just as static and just as valid as before. You're betting that 30 days is short enough to matter and long enough not to break anything, and you're usually wrong in both directions.
TTL flips the default. The credential is dead unless something actively keeps renewing it. There's no rotation calendar to miss, no "we forgot this key existed" three years later. Revocation is immediate and per-lease instead of a mass invalidation that takes out half the fleet. Leak detection becomes less urgent because the leak has an expiry baked in.
Caching and refresh, without shooting yourself in the foot#
You don't call STS on every API request — that's a throttle waiting to happen and it adds latency to everything. The AWS SDKs cache the session in memory and refresh it automatically a few minutes before Expiration. Vault's Agent and the various SDK helpers do the same with lease renewal. If you're wiring this yourself, the rule is refresh before expiry, not on the failure. Waiting for the first 403 to trigger a refresh means real requests eat the failure while the retry warms up.
A few things that will bite you:
- Clock skew. Expiry is absolute time. A host running minutes fast will reject creds that the issuer thinks are still valid, or trust ones that just died. Run NTP and treat drift as a real alert, not a nuisance.
- Refresh-before-expiry margin. Leave 5+ minutes of headroom. Refreshing at expiry minus 10 seconds guarantees a race under load.
- Never log temp creds. They feel safe because they're short-lived, so people get sloppy and dump the whole credential object into debug logs. A one-hour window is still an hour, and CI logs live a lot longer than that. Scrub
SecretAccessKey,SessionToken, and Vault passwords at the logging boundary. - Max session duration mismatches. If your job needs 90 minutes and the role caps at 60, you won't find out until it fails 60 minutes in. Match the role config to the real workload.
The call we'd make#
Kill static keys everywhere they can be killed, and make short-lived the only path that works — not the recommended one. STS for anything talking to AWS, Vault dynamic secrets for databases and everything else with a password. Set sessions to the shortest duration the workload tolerates, run NTP so expiry math stays honest, and grep your logs for AKIA and raw secrets until both come back empty. The goal isn't credentials that rotate faster. It's credentials that a thief inherits already half-dead.
Stay Updated
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
GitLab CI/CD Best Practices in 2026: Pipelines You Can Trust
A production-focused GitLab CI/CD guide: include/extends templates, rules and workflow, needs DAGs, cache vs artifacts, protected environments, masked/protected variables and Vault, built-in security scanning, and review apps — with copy-paste examples.
OIDC Federation Beyond GitHub — GitLab, Buildkite, and Generic Providers
GitHub Actions OIDC gets all the attention, but GitLab, Buildkite, and CircleCI issue the same signed tokens. Here's how to trust them without opening a hole.
More from Cloud
Explore more articles in this category
Best Serverless Databases in 2026 (Compared)
A practitioner comparison of the leading serverless databases by use case, cold-start behavior, branching, pricing model, and lock-in.
Cloudflare D1: The Edge SQLite Database Guide (2026)
A practitioner's look at Cloudflare D1, the serverless SQLite database built for Workers, covering setup, read replication, limits, and fit.
Neon vs PlanetScale: Serverless SQL Compared (2026)
A practitioner comparison of Neon's serverless Postgres against PlanetScale's Vitess-backed MySQL to help you pick the right database.
You might have missed
Evergreen posts worth revisiting.