Repository navigation
STS Web Identity
The standalone IAM service can turn an ID token issued by an external OpenID Connect provider into temporary S3 credentials. A CI job, a Kubernetes pod, or any workload that already holds an OIDC token can therefore talk to the S3 gateway without a long-lived access key ever being created, distributed, or rotated.
- The federation flow
- Registering an OIDC provider
- Automatic thumbprint fetching
- Private and isolated providers
- Role trust policies
- Exchanging a token for credentials
- How the token is validated
- Session policies
- GetCallerIdentity
- Session credential limits
- Worked example with GitHub Actions
- Troubleshooting
sequenceDiagram
participant Adm as Administrator
participant IdP as OIDC provider
participant IAM as Standalone IAM service
participant App as Workload
participant GW as S3 gateway
Adm->>IAM: CreateOpenIDConnectProvider (Url, ClientIDList)
IAM->>IdP: TLS handshake (thumbprint auto-fetch)
Adm->>IAM: CreateRole (trust policy naming the provider ARN)
Adm->>IAM: PutRolePolicy (S3 permissions)
App->>IdP: request an ID token for the audience
IdP-->>App: signed JWT
App->>IAM: AssumeRoleWithWebIdentity (unauthenticated)
IAM->>IdP: /.well-known/openid-configuration, then JWKS
IAM-->>App: ASIA... + SecretAccessKey + SessionToken + Expiration
App->>GW: S3 request signed with the session credentials
GW->>IAM: private endpoint: resolve session, evaluate policy
IAM-->>GW: role policy decision + session policy decision
GW-->>App: S3 response
Two steps carry behavior that is specific to this implementation:
-
AssumeRoleWithWebIdentitycarries no credentials of its own — the ID token is the credential. What the service checks, and in which order, is Validation order. - The gateway resolves the session over its private endpoint to the IAM service, and authorizes each request against the intersection of the role's inline policy and the optional session policy, on top of the usual bucket policy and ACL checks.
The IAM service needs outbound access to the identity provider: once at enrollment unless auto-fetch is disabled or the provider is plaintext HTTP, and on every assume request that misses the JWKS cache. The secure default requires verified HTTPS at a public address. Explicit endpoint-policy flags support private HTTPS and loopback sidecars; see Private and isolated providers.
CreateOpenIDConnectProvider is an ordinary IAM action, and IAM API carries its parameter table and signing rules. What follows is what a federation user needs on top of them.
aws --endpoint-url http://iam.example.com:7070 iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list versitygw-ciEvery other provider action addresses the provider by its ARN, which is derived from the URL by stripping the scheme: arn:aws:iam::000000000000:oidc-provider/<url without https://>. A provider with a path, https://studio.datachain.ai/api, becomes …:oidc-provider/studio.datachain.ai/api. The account ID is always 000000000000 — the service models a single fixed account — and an ARN naming any other account returns AccessDenied / User: arn:aws:iam::000000000000:root is not authorized to perform this action on resource: <arn> at HTTP 403.
An account may hold at most 100 providers; beyond that the call returns LimitExceeded / Cannot exceed quota for OpenIDConnectProvidersPerAccount: 100 with HTTP 409. Registering a URL that already exists returns EntityAlreadyExists / Provider with url https://<url> already exists., also HTTP 409.
The URL is validated before anything else happens, in this order:
| Input | Code | Message |
|---|---|---|
| Absent or empty | ValidationError |
1 validation error detected: Value at 'url' failed to satisfy constraint: Member must not be null |
Longer than 255 characters (measured on the raw URL, https:// included) |
ValidationError |
1 validation error detected: Value at 'url' failed to satisfy constraint: Member must have length less than or equal to 255 |
No scheme at all, e.g. example.com
|
ValidationError |
Invalid Open ID Connect Provider URL |
A scheme other than https, e.g. http://example.com
|
InvalidInput |
Invalid Open ID Connect Provider URL. The URL must begin with https://. — HTTP is accepted only with --oidc-allow-insecure-transport
|
| Unparseable, or an empty host | ValidationError |
Invalid Open ID Connect Provider URL |
| Userinfo, a query string, a fragment, or an explicit port | InvalidInput |
Invalid Open ID Connect Provider URL. — an explicit port is accepted only with --oidc-allow-private-endpoints
|
A hostname whose labels are not DNS-legal, e.g. exa_mple.com
|
InvalidInput |
Invalid Open ID Connect Provider URL. |
The userinfo/query/fragment/port and invalid-hostname cases use
Invalid Open ID Connect Provider URL. with a trailing period. Scheme
and parse failures use variants without one.
-
A path is allowed. Only userinfo, query, fragment and an explicit port are rejected.
https://studio.datachain.ai/apiis a legal provider URL. -
An IP literal host is accepted by the URL validator (IPv4 or IPv6), but thumbprint auto-fetch and JWKS retrieval reject loopback, private, and link-local addresses unless
--oidc-allow-private-endpointsis enabled. -
A trailing slash is part of the provider's identity.
https://idp.example.comandhttps://idp.example.com/are two distinct providers with two distinct ARNs, and a token'sissmust match the registered form exactly. -
The URL is never case-folded.
https://EXAMPLE.comandhttps://example.comare different providers.
ClientIDList holds the audience values the IdP will put in the token's aud (or azp) claim. At assume time the effective audience must be an exact, case-sensitive member of this list, or the token is rejected. More than 100 entries yields LimitExceeded / Cannot exceed quota for ClientIdsPerOpenIdConnectProvider: 100 (HTTP 409); an entry over 255 characters yields ValidationError / …Value at 'clientID' failed to satisfy constraint: Member must have length less than or equal to 255.
There is no minimum length, no character-set check, and no de-duplication at create time. A provider with an empty ClientIDList can never satisfy the audience check, so no token will ever be accepted for it; if you create a provider without --client-id-list, add one with AddClientIDToOpenIDConnectProvider before trying to federate.
By default, thumbprints are a trust anchor for later discovery and JWKS
fetches, which is what makes an IdP behind a private CA usable. They are
ignored when --oidc-allow-insecure-transport disables TLS authentication.
| Condition | Result |
|---|---|
| More than 5 entries |
InvalidInput / Thumbprint list must contain fewer than 5 entries. — despite the wording, exactly 5 is accepted |
| An entry that is not exactly 40 characters |
InvalidInput / Thumbprint must be exactly 40 characters.
|
An empty list on UpdateOpenIDConnectProviderThumbprint
|
InvalidInput / Thumbprint list must contain at least one entry.
|
ThumbprintList omitted entirely on create |
The service fetches one — see Automatic thumbprint fetching |
Thumbprints are not checked for hex characters — a 40-character run of z is accepted and stored. They are lowercased on storage, so an uppercase submission reads back lowercase.
IAM API has the full CRUD surface, including the ordered ARN validation and the two different NoSuchEntity wordings. The behaviors that matter to federation are:
| Action | Behavior |
|---|---|
AddClientIDToOpenIDConnectProvider |
Takes a single scalar ClientID, not a list. Idempotent, and that check runs before the 100-entry quota, so re-adding to a full provider still succeeds. |
RemoveClientIDFromOpenIDConnectProvider |
Removes the first exact, case-sensitive match. No lower bound — the list can be emptied. |
UpdateOpenIDConnectProviderThumbprint |
Replaces the whole list; it is not a merge. ThumbprintList is required here and auto-fetch never applies, whatever the service was started with. |
DeleteOpenIDConnectProvider |
Not idempotent. There is no conflict check: a provider can be deleted while role trust policies still name its ARN. |
Deleting a provider does not invalidate sessions already issued through it, and it does not touch the roles that trusted it. Those roles simply stop working: their trust statements no longer resolve to a provider, and AssumeRoleWithWebIdentity returns AccessDenied / Not authorized to perform sts:AssumeRoleWithWebIdentity.
Provider tag keys are compared byte for byte, unlike user and role tag keys which are compared case-insensitively — env and ENV are two independent tags on a provider. See IAM API for the rest of the tagging semantics.
When CreateOpenIDConnectProvider is called with ThumbprintList entirely omitted, the service obtains a thumbprint itself:
- The host is taken from the provider URL up to the first
/, so a provider with a path dials only its host. - The hostname is resolved once with a single DNS lookup. A lookup failure, or a lookup that returns no addresses, aborts the fetch.
-
SSRF guard. If any resolved address is loopback, RFC 1918 private, link-local unicast, link-local multicast, unspecified, or multicast, the fetch is refused before a connection is attempted. This covers
169.254.169.254and other cloud metadata endpoints.--oidc-allow-private-endpointsexplicitly waives this address check. - The service dials the first pre-validated address on port 443, or the provider's explicit port when private endpoints are allowed, never re-resolving the hostname. This closes the DNS-rebinding window. The original hostname is presented via SNI.
- The TLS handshake is fully verified by default: the presented chain must validate against the host system's trust store and match the provider hostname.
--oidc-allow-insecure-transportdisables this verification. - The thumbprint is the SHA-1 hash of the DER bytes of the last (top-most) certificate in the chain the peer presented — the intermediate or root CA, not the leaf — hex-encoded and lowercased. Exactly one thumbprint is stored.
The whole operation, DNS included, is bounded by an 8 second timeout.
Step 5 is why the handshake must be verified: the certificate observed here is persisted as a long-lived trust anchor and compared against every future JWKS fetch for this provider (see Discovery and JWKS fetching), so an unauthenticated handshake would let an active network or DNS attacker pin a chain they control.
Without endpoint relaxations, a private or self-hosted IdP on an internal CA cannot use auto-fetch. With private endpoints enabled, a private HTTPS IdP can use auto-fetch only if its certificate already verifies through the IAM host's system trust store. Insecure transport also permits auto-fetch from an untrusted HTTPS endpoint, but doing so pins a certificate obtained over an unauthenticated connection and provides no security benefit while that flag remains enabled.
Any failure — DNS, a disallowed address, a dial error, or a verification failure — is reported identically, with the reason written only to the debug log:
Code: OpenIdIdpCommunicationError
Message: Could not connect to https://<provider-url>
HTTP: 400
Supply ThumbprintList explicitly for such providers, having obtained the fingerprint through some independently verified channel.
--disable-oidc-thumbprint-autofetch reject CreateOpenIDConnectProvider requests that omit ThumbprintList instead of auto-fetching it over an outbound TLS connection (default: false) [$VGW_IAM_DISABLE_OIDC_THUMBPRINT_AUTOFETCH]
This flag belongs to the versitygw iam subcommand and defaults to false. Set it in deployments that are air-gapped or that restrict egress, where the IAM service must make no outbound connection of its own during provider enrollment. The Helm chart exposes it as iamServer.disableOidcThumbprintAutofetch.
With the flag set, a CreateOpenIDConnectProvider request that omits ThumbprintList is rejected outright and no outbound connection is attempted at all:
Code: ValidationError
Message: 1 validation error detected: Value at 'thumbprintList' failed to satisfy constraint: Member must not be null
HTTP: 400
Important
This flag does not disable the network fetch that happens at AssumeRoleWithWebIdentity time. Every assume request that misses the cache still retrieves the issuer's /.well-known/openid-configuration document and its JWKS over HTTPS. There is no flag to turn that off and no way to preload a key set, so a standalone IAM service with no outbound network access can register providers but can never complete a web identity exchange.
Take the last certificate in the chain the issuer presents and SHA-1 hash it:
openssl s_client -connect token.actions.githubusercontent.com:443 \
-servername token.actions.githubusercontent.com -showcerts </dev/null 2>/dev/null \
| awk '/-----BEGIN CERTIFICATE-----/{n++} n{print > ("cert-" n ".pem")}'
openssl x509 -in "$(ls cert-*.pem | sort -V | tail -1)" -noout -fingerprint -sha1 \
| sed 's/.*=//' | tr -d ':' | tr 'A-Z' 'a-z'The value produced this way is byte-identical to what auto-fetch stores. Run it from a host you trust and compare the result against a fingerprint the IdP operator published out of band.
You may register up to five thumbprints, which is how you stage a CA rotation: add the new anchor alongside the old one with UpdateOpenIDConnectProviderThumbprint (remembering that it replaces the whole list), then drop the old one once the IdP has switched.
Two versitygw iam flags relax the secure public-provider defaults:
--oidc-allow-private-endpoints allow OIDC provider URLs that resolve to loopback/private/link-local addresses and that carry an explicit port [$VGW_IAM_OIDC_ALLOW_PRIVATE_ENDPOINTS]
--oidc-allow-insecure-transport allow plaintext http OIDC provider URLs and skip TLS certificate verification for https ones [$VGW_IAM_OIDC_ALLOW_INSECURE_TRANSPORT]
The first flag changes where the service may connect, not how TLS is authenticated. It allows private, loopback, link-local, unspecified, and multicast targets and explicit ports. HTTPS hostname, chain, and registered thumbprint checks still apply.
The second changes how the provider is authenticated. It permits HTTP provider URLs, HTTP discovery/JWKS URLs and redirects, and accepts any certificate presented by an HTTPS provider without hostname, CA-chain, or thumbprint verification. It does not waive the private-address guard or explicit-port restriction, so a loopback sidecar normally needs both flags.
| Deployment | Flags | Thumbprint guidance |
|---|---|---|
| Public HTTPS IdP | Neither | Omit to auto-fetch, or supply explicitly |
| Private HTTPS IdP with public/system-trusted CA | --oidc-allow-private-endpoints |
Auto-fetch is available |
| Private HTTPS IdP with internal CA | --oidc-allow-private-endpoints |
Supply through an independently verified channel |
| HTTP loopback sidecar | Both | Omit; HTTP presents no certificate and stores an empty list |
For an HTTPS provider with an explicit port:
aws --endpoint-url http://127.0.0.1:7070 iam \
create-open-id-connect-provider \
--url https://spire-oidc.spire.svc.cluster.local:8443 \
--client-id-list versitygw \
--thumbprint-list '<40-character CA SHA-1 thumbprint>'For a loopback HTTP sidecar:
aws --endpoint-url http://127.0.0.1:7070 iam \
create-open-id-connect-provider \
--url http://127.0.0.1:8080 \
--client-id-list versitygwThe HTTP provider ARN contains the retained scheme:
arn:aws:iam::000000000000:oidc-provider/http://127.0.0.1:8080
Its token iss must be exactly http://127.0.0.1:8080, and trust-policy
condition keys use that same full prefix. HTTPS providers continue to use the
scheme-stripped ARN form.
Warning
Allowing private endpoints removes the barrier that prevents the IAM
process from reaching cloud metadata services and internal control planes.
Discovery documents can select a JWKS URI and both responses can redirect,
so the relaxation applies beyond the originally registered host. Restrict
provider administration, constrain IAM egress to the intended IdP, and
explicitly deny metadata addresses such as 169.254.169.254.
[!DANGER] Insecure transport disables all cryptographic server authentication, including a configured
ThumbprintList. An attacker able to intercept or redirect the network path can substitute discovery metadata and signing keys, then mint tokens accepted by the IAM service. Use this mode only for a loopback-bound sidecar in the IAM pod or an equivalently isolated network namespace; private HTTPS is the preferred deployment.
The one-resolution/direct-IP DNS-rebinding defense, 8-second fetch timeout, five-redirect cap, 1 MiB body cap, JWKS key limits, cache, and forced-refresh rate limit remain active under both flags. See Standalone IAM Setup for deployment safeguards and Helm configuration.
A role's AssumeRolePolicyDocument decides who may assume it. Its grammar is close to the inverse of an identity policy:
| Element | Identity policy | Trust policy |
|---|---|---|
Principal |
Forbidden | Required |
NotPrincipal |
Forbidden | Forbidden, with a distinct message per effect |
Action / NotAction
|
Required, vendor-prefixed | Required, and every value must begin with sts:
|
Resource / NotResource
|
Required | Forbidden |
Condition |
Optional | Optional, but effectively mandatory for shared providers |
-
Version, if present, must be2008-10-17or2012-10-17; policy variables (${…}) are substituted only under2012-10-17. A bad value returnsMalformedPolicyDocument/The policy must contain a valid version string. - The usual document skeleton applies, with these exact messages: an absent or empty
StatementgivesCould not parse the policy: Statement is empty!; duplicate non-emptySidvalues giveThe Statement Ids in the policy are not unique; a missingEffectgivesMissing required field Effectand any value other thanAllow/DenygivesInvalid effect: <value>. -
Principalmust be a JSON object. A bare string — including"*"— yieldsPrincipal must be a JSON object.; an empty object yieldsMissing required field Principal cannot be empty!. Only the keysAWS,ServiceandFederatedare accepted; anything else yieldsInvalid principal in policy: "<key>".CanonicalUseris deliberately unsupported.-
Federatedvalues are the OIDC provider ARNs, as a string or an array. This is the only principal form web identity federation consults. -
Servicevalues are checked against a fixed list ofs3.amazonaws.com,sts.amazonaws.com,iam.amazonaws.com. Anything else —ec2.amazonaws.comincluded, which real AWS accepts — yieldsInvalid principal in policy: "SERVICE":"<value>". -
AWSvalues are shape-checked only.
-
-
NotPrincipalis always rejected: withAllowthe message isAllow with NotPrincipal is not allowed., otherwiseAssumeRole policy must not contain NotPrincipal field. - Exactly one of
ActionorNotActionmust be present, and every value must start with the literalsts:— sosts:*is fine but the bare wildcard"*"is rejected withAssumeRole policy may only specify STS AssumeRole actions.The suffix aftersts:is not checked against a catalogue. -
ResourceyieldsHas prohibited field Resource;NotResourceyieldsAssumeRole policy must not contain resources. - The document may be at most 2048 bytes; exceeding it returns
LimitExceeded/Cannot exceed quota for ACLSizePerRole: 2048with HTTP 409, but only after the grammar check passes, so a document that is both oversized and malformed reports the grammar error. - Duplicate JSON keys are rejected at any nesting depth, and unknown fields inside a statement are rejected outright — a typo such as
"Conditon"is a hard error, not a silently ignored key.
At assume time, only statements whose Action/NotAction covers sts:AssumeRoleWithWebIdentity and whose Principal.Federated resolves to a registered provider whose URL equals the token's issuer are considered. Action matching is a case-insensitive glob, so sts:* and sts:AssumeRole* both match. Principal.AWS and Principal.Service statements are ignored for this flow. A matching Deny beats every Allow, and a Condition that cannot be evaluated is treated as a Deny.
A good trust policy pins both the audience and the subject:
{
"Version": "2012-10-17",
"Statement": {
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "versitygw-ci"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:acme/backups:ref:refs/heads/*"
}
}
}
}Condition keys here are prefixed with the scheme-stripped provider URL, so they read token.actions.githubusercontent.com:sub rather than a bare sub, and any top-level claim beyond the well-known ones is exposed as <provider-url>:<claim>. Key names are matched case-insensitively; values are not. Policy Conditions lists the keys a trust policy sees, the claim-to-string coercion rules, and every operator available.
Many public OIDC issuers mint tokens for every customer of the service, so trusting the issuer alone would trust the whole internet. For a list of known shared-audience providers, versitygw rejects a trust policy at write time unless its Condition scopes that provider's tenancy claim:
MalformedPolicyDocument: The trust policy trusts shared OpenID Connect provider
"token.actions.githubusercontent.com" without a Condition scoping
"token.actions.githubusercontent.com:sub" to your own tenant.
| Provider URL | Required condition key |
|---|---|
token.actions.githubusercontent.com, vstoken.actions.githubusercontent.com, oidc-configuration.audit-log.githubusercontent.com, gitlab.com, agent.buildkite.com, app.terraform.io, oidc.codefresh.io, studio.datachain.ai/api, scalr.io, tokens.cloud.shisho.dev, proidc.upbound.io
|
<url>:sub |
api.pulumi.com/oidc, sandboxes.cloud, oidc.vercel.com
|
<url>:aud |
sts.windows.net/33e01921-4d64-4f8c-a055-5bdaffd5e33d (Microsoft Sentinel) |
sts:RoleSessionName |
cognito-identity.amazonaws.com (a bare identifier, not an ARN) |
cognito-identity.amazonaws.com:aud, else A condition block must be present for the Cognito provider
|
What counts as "scoping":
- The condition must use a positive string operator:
StringEquals,StringLike, orStringEqualsIgnoreCase, optionally qualified withForAllValues:orForAnyValue:. TheNot…variants do not count. - At least one value must be neither empty nor
"*". - Under
StringLikespecifically, a value made up entirely of wildcard characters is rejected —"*","**","?"and"*?*"all fail. UnderStringEquals,*and?are literal characters, so"**"passes.
A private or self-hosted provider that is not on this list imposes no extra requirement, but scoping the subject is still the right thing to do.
UpdateAssumeRolePolicy replaces a role's trust policy wholesale. It runs the same grammar validation, the same shared-provider tenancy check, and the same 2048-byte limit as CreateRole, and it takes effect immediately for new assume requests. Sessions already issued are unaffected — they keep the permissions they were minted with until they expire.
AssumeRoleWithWebIdentity is the only route on the whole IAM/STS endpoint that requires no credentials. It is not merely tolerant of a bad signature: the request never reaches the authentication middleware, so a signature, if one is present, is never checked at all. That is by design — the caller has no identity yet, and the ID token is itself the credential being presented. It shares the listener with everything else and is told apart by Version=2011-06-15.
What actually gates the call is therefore the combination of the role's trust policy, the provider's client ID list, and the token's signature. The only server-side throttles are the global --max-requests limiter, if you have configured one, and the 1000-live-sessions-per-role cap.
IAM API carries the parameter table. Two constraints bite in practice: RoleArn must equal the role's stored ARN exactly, path included, and PolicyArns and ProviderId are rejected outright with InvalidInput / PolicyArns is not supported by this implementation. (triggered by any PolicyArns.member. parameter) and InvalidInput / ProviderId is not supported by this implementation.
A non-numeric DurationSeconds returns MalformedInput with an empty message; a value above the role's MaxSessionDuration returns ValidationError / The requested DurationSeconds exceeds the MaxSessionDuration set for this role. The default is 3600 regardless of the role's own MaxSessionDuration.
Warning
Send this request as a POST with an application/x-www-form-urlencoded body, not as a GET with query parameters. The IAM listener's request-header buffer is 8 KiB, and a real ID token in a query string will usually overflow it. When it does, the request is rejected by the HTTP layer before routing: the caller gets HTTP 500 InternalFailure and no line is written to the access log. Every AWS SDK and the AWS CLI already use POST here.
The call needs no credentials, so it works with --no-sign-request or with none configured at all:
aws --endpoint-url http://iam.example.com:7070 --region us-east-1 \
sts assume-role-with-web-identity \
--role-arn arn:aws:iam::000000000000:role/gha-backups \
--role-session-name ci-run-1234 \
--web-identity-token "$TOKEN"The response is AWS's own AssumeRoleWithWebIdentityResponse shape. Four elements are worth reading twice:
| Element | Value |
|---|---|
Audience |
The effective audience — azp when the token carried one, otherwise aud. Omitted when empty. |
AssumedRoleUser.Arn |
arn:aws:sts::000000000000:assumed-role/<RoleName>/<RoleSessionName> — note the sts service and assumed-role resource type, not the role's own arn:aws:iam::…:role/…. AssumedRoleId is <RoleId>:<RoleSessionName>, the role ID being AROA plus 17 characters. |
Provider |
The raw iss claim, scheme included — not the provider ARN. |
PackedPolicySize |
The session policy's size as a percentage of the 2048-byte budget. Omitted entirely when no Policy was supplied. |
The S3 gateway reads X-Amz-Security-Token from the request, resolves the session over the private endpoint to the IAM service, and receives back only a derived SigV4 signing key — the session's secret never crosses that channel. Presigned URLs work too; the token rides in the signed canonical query string.
On the public IAM API there is one extra rule for header-based SigV4 with an ASIA… key: X-Amz-Security-Token must appear in SignedHeaders alongside host. Presigned (query) requests need only host. Pairing a security token with the root account or any AKIA… key is rejected up front with InvalidClientTokenId.
Session credentials only work when the S3 gateway is running against the standalone IAM service. Every other IAM backend — --iam-dir, LDAP, Vault, FreeIPA, S3 — rejects a session token or an ASIA… key outright.
The order matters, because it decides which error you see when more than one thing is wrong.
- Request shape, in this order: parameter presence, length and character set;
PolicyArns/ProviderIdrejection;DurationSecondsparsing and range; sessionPolicysize, character set and identity-policy grammar. -
Structural JWT parse — the signature is not verified yet. A token that is not a well-formed JWT fails here with
InvalidIdentityToken/The ID Token provided is not a valid JWT. (You may see this error if you sent an Access Token). This runs before the role is looked up, so a malformed token gets the same answer whether or not the role exists. -
RoleArnis parsed to a role name, the role is loaded, and the supplied ARN must equal the role's stored ARN exactly. -
DurationSecondsagainst the role'sMaxSessionDuration. -
issmust be present and a non-empty string. - Audience resolution, including the
azpoverride. - Trust policy evaluation.
- The provider is re-read, and the effective audience must be an exact member of its
ClientIDList. - Signature verification against the JWKS fetched from the issuer.
-
exp, theniat,subandnbf. - Credentials are minted and the session stored.
Two consequences are worth internalizing:
- The trust policy is evaluated before the signature. A token with a garbage signature that also fails the trust policy reports the trust failure, never a signature error.
-
expis checked after signature verification. An expired token presented while the IdP is unreachable reports the IdP-communication error, notExpiredTokenException.
The issuer is matched by stripping a leading https:// from iss and comparing the remainder for exact string equality against a registered provider's stored URL. Only https:// is stripped, so an iss with any other scheme can never match; and a trailing slash difference is a mismatch.
The aud claim may be a single string or an array; empty strings are dropped.
- If
azpis present and non-empty, it is always the effective audience — even whenaudholds exactly one value. The originalaudvalue or values are then additionally exposed as<provider-url>:oaud. - If
azpis absent andaudholds more than one value, the token is rejected:InvalidIdentityToken/Token audience contains more than one audience while authorized party is not present. This is a hard requirement, not a warning. - If there is no usable
audat all, the token is rejected with the genericInvalidIdentityTokenclaims message.
The effective audience is what the trust policy's <provider-url>:aud key holds, what the ClientIDList membership check compares, and what the response's Audience element reports.
To verify a signature the service fetches
<provider-url>/.well-known/openid-configuration, then the jwks_uri it
advertises. The provider URL is HTTPS unless insecure transport explicitly
allowed an HTTP provider.
- The discovery document's
issuerfield must equal the registered provider endpoint exactly, including scheme, explicit port, and trailing slash.jwks_urimust begin withhttps://, orhttp://when insecure transport is enabled. - An empty
keysarray is an error, and so is a key set with more than 100 RSA keys or more than 100 EC keys. -
Accepted signature algorithms:
RS256,RS384,RS512,ES256,ES384,ES512. Anything else —HS*,PS*,EdDSA,none— is rejected. Accepted JWK types areRSAandECon curvesP-256,P-384andP-521. -
Key selection: a set with exactly one key is used regardless of
kidwhen the token has nokid, the key has nokid, or they match. Otherwise the key whosekidequals the token header's is used. - Caching: key sets are cached in memory for 5 minutes, keyed by the issuer URL plus the sorted thumbprint list. The cache is per-process — not shared between replicas, not persisted. Because the thumbprint list is part of the key, changing a provider's thumbprints invalidates its cached JWKS immediately rather than after the normal lifetime.
- Deduplication: concurrent fetches for the same cache key coalesce into a single outbound round trip.
-
Forced refresh: an unrecognized
kidtriggers one refresh that bypasses the TTL, but at most once every 30 seconds per key — otherwise anyone knowing a trusted issuer, audience and role ARN could force an outbound fetch per token, before any authentication. The timestamp is stamped before the fetch is attempted, so a slow or failing issuer is negatively cached too. Within that window the still-cached key set is returned unchanged, and with no cached key material at all the request fails closed. - SSRF-safe HTTP client: both fetches use a client with an 8 second timeout, at most 5 redirects, a 1 MiB response body cap, and the same disallowed-address guard as the thumbprint fetch, applied per TCP connection so redirect targets are covered too. Redirects must remain HTTPS unless insecure transport allows HTTP. The private-endpoint flag waives the address check for every connection and redirect target, but DNS is still resolved once per connection and the selected IP is dialed directly. A non-200 response is an error.
- Thumbprint pinning: by default, if the provider has thumbprints, the top certificate of the presented chain is SHA-1 hashed and compared case-insensitively against each of them. On a match, that certificate becomes the sole trust root and the leaf must chain to it and match the requested hostname. When no thumbprint matches, ordinary hostname and system-CA verification applies. With insecure transport enabled, all of these checks are skipped, including thumbprint pinning.
| Cause | Code | HTTP | Message |
|---|---|---|---|
| Not a well-formed JWT | InvalidIdentityToken |
400 | The ID Token provided is not a valid JWT. (You may see this error if you sent an Access Token) |
No usable aud; issuer matches no trust statement; condition failed; audience not in ClientIDList; signature invalid; missing exp; nbf in the future |
InvalidIdentityToken |
400 | The web identity token provided could not be validated. See the AssumeRoleWithWebIdentity documentation for requirements. |
Multi-valued aud with no azp
|
InvalidIdentityToken |
400 | Token audience contains more than one audience while authorized party is not present |
| Discovery document or JWKS could not be retrieved | InvalidIdentityToken |
400 | Couldn't retrieve verification key from your identity provider, please reference AssumeRoleWithWebIdentity documentation for requirements |
Missing iat or sub
|
InvalidIdentityToken |
400 |
Missing a required claim: iat. / Missing a required claim: sub.
|
| Expired | ExpiredTokenException |
400 | Token expired: current date/time <now> must be before the expiration date/time <exp> |
Role not found, ARN mismatch, no resolvable federated principal, or an explicit trust Deny
|
AccessDenied |
403 | Not authorized to perform sts:AssumeRoleWithWebIdentity |
| Duration above the role's maximum | ValidationError |
400 | The requested DurationSeconds exceeds the MaxSessionDuration set for this role. |
| More than 1000 live sessions for the role | Throttling |
400 | Rate exceeded. |
exp and nbf are both evaluated with 5 minutes of clock-skew leeway, so a token expired by less than five minutes is still accepted. exp, iat and sub are all required; nbf is optional.
The AccessDenied message is deliberately identical for "no such role", "the ARN does not match", "no federated principal resolves" and "a Deny matched" — the response never confirms whether the role exists.
The optional Policy parameter attaches an inline policy to the session itself. It uses identity policy grammar, not trust policy grammar: Principal is forbidden, Action and Resource are required in every statement, actions must be vendor-prefixed, and resources must be ARN-shaped or *. The maximum size is 2048 bytes — much smaller than the 131072-byte limit on a PolicyDocument parameter. Pass it as --policy '{"Version":"2012-10-17",…}' on the assume call.
An Allow must appear on both sides: the session policy can only narrow the role's permissions, never widen them. Identity Policies has the full evaluation model.
Two properties of that intersection are specific to this gateway:
- A session policy filters everything, including permissions that came from a bucket policy rather than from the role. (With no role policy, a bucket policy granting
s3:GetObjectands3:PutObject, and a session policy allowing onlys3:GetObject, the Get succeeds and the Put is denied.) - A policy document that cannot be parsed or that fails validation is treated as a
Denyfor the entire evaluation, not skipped — including a document that only contains an unrecognized condition operator in an unrelated statement. The session policy is stored with the session and applies for its whole lifetime; it cannot be changed afterwards.
GetCallerIdentity is the other STS action the endpoint serves. Unlike AssumeRoleWithWebIdentity it is authenticated: SigV4 scoped to service sts and region us-east-1. It takes no parameters beyond Action and Version=2011-06-15, and it needs no identity policy grant — it never reaches the policy middleware, matching AWS.
| Caller | Arn |
UserId |
Account |
|---|---|---|---|
| A session | arn:aws:sts::000000000000:assumed-role/<RoleName>/<RoleSessionName> |
<RoleId>:<RoleSessionName> |
000000000000 |
| An IAM user | the user's stored ARN | the user's AIDA… ID |
000000000000 |
| Root | arn:aws:iam::000000000000:root |
000000000000 |
000000000000 |
It is the quickest way to confirm a freshly minted session works, and it still answers correctly for a session whose role has since been deleted, because the session record carries the role name and ID. An unauthenticated call returns MissingAuthenticationToken; an unknown or expired key returns InvalidClientTokenId / The security token included in the request is invalid. with HTTP 403.
| Property | Value |
|---|---|
| Access key form |
ASIA followed by 17 characters from ABCDEFGHIJKLMNOPQRSTUVWXYZ234567
|
| Secret access key | 40 characters (base64 of 30 random bytes) |
| Session token | 128 random bytes, base64url-encoded — an opaque value looked up in the IAM service's own session store, not a self-describing token |
| Lifetime | 900–43200 seconds, default 3600 regardless of the role's MaxSessionDuration and further capped by it |
| Live sessions per role | 1000, then Throttling / Rate exceeded.
|
| Session policy size | 2048 bytes |
Expired sessions stop resolving the moment they expire. With the file-backed store they are pruned whenever a new session is created; with the Vault store each session is written with a delete_version_after matching its expiration, so Vault reaps it.
Beyond expiry, a few sharp edges are worth knowing before you design around session credentials.
-
A session access key is not a usable identifier, but a session is nameable. An
ASIA…key is reported as nonexistent by the gateway's account resolution even while the session is live, so it can never be an ACL grantee or a bucket owner — those are access key IDs, see Standalone IAM. A bucket policy names a session by ARN instead:arn:aws:sts::000000000000:assumed-role/<RoleName>/<RoleSessionName>covers exactly that one session, and the role's own ARNarn:aws:iam::000000000000:role/<RoleName>covers every session of the role — the only way to cover sessions that did not exist when the policy was written, since no wildcard is allowed inside an ARN. The role must exist; the session name is not checked for existence but must satisfy theRoleSessionNamegrammar. See Bucket Policies for the accepted principal forms. -
Deleting the role does not invalidate live sessions' authentication. Such a session keeps authenticating and
GetCallerIdentitykeeps working, but it carries no identity policies, so every policy-gated action denies. Deleting and recreating a role with the same name does not let old sessions inherit the new role's policies — the session records the role's ID and ARN and requires both to still match. -
There are no session tags.
AssumeRoleWithWebIdentityhas noTagsorTransitiveTagKeysparameter, soaws:PrincipalTag/<key>for a session resolves to the assumed role's tags, for the session's whole lifetime. -
Browser POST uploads need an explicit token condition. A POST-Object form built with session credentials must include a matching
x-amz-security-tokencondition in its policy document, or the upload is rejected. Onlyfile,policy,x-amz-signatureandx-ignore-*fields are exempt from policy coverage, because the POST signature covers only the base64 policy document — an uncovered token field would be entirely unbound, and anyone could swap in another session's token. AWS SDK POST presigners emit the condition; hand-written forms usually do not. -
AssumeRole,AssumeRoleWithSAML,GetSessionTokenandGetFederationTokendo not exist here. OnlyAssumeRoleWithWebIdentityandGetCallerIdentityare implemented. Calling another STS action with a normal SDK yieldsSignatureDoesNotMatch/Credential should be scoped to correct service: 'iam'.with HTTP 400, because the request falls through to the IAM authentication path before the action is ever looked up.
This example mirrors the project's own live end-to-end test, so the shape is known to work against a real IdP. GitHub Actions is convenient here because it is a public, free OIDC issuer with a CA-issued certificate, reachable from inside any workflow run.
Register the provider with the create-open-id-connect-provider call from Registering an OIDC provider — omitting --thumbprint-list lets the service fetch and verify GitHub's chain itself — then create the role with the trust policy from A scoped trust policy and attach its S3 permissions with put-role-policy.
- Choose an audience of your own —
versitygw-cihere — rather than GitHub's default audience, which is the repository owner's URL. A token minted for your own audience cannot be replayed against an unrelated cloud trust relationship that trusts the same repository. - GitHub's
subclaim differs by trigger: a push tomainproducesrepo:acme/backups:ref:refs/heads/main, while a pull request producesrepo:acme/backups:pull_request. Pin the exact form your workflow will produce. -
permissions: id-token: writeis what makes GitHub injectACTIONS_ID_TOKEN_REQUEST_URLandACTIONS_ID_TOKEN_REQUEST_TOKENinto the job. Without it those variables are simply absent — and GitHub downgrades them to read-only for pull requests from forks no matter what the workflow asks for, which is the real security boundary. - The ID token is a real bearer credential that GitHub does not scrub from logs, so never echo
$TOKENand never include a response body in an error message built around it.
Warning
Do not settle for repo:acme/backups:*. That wildcard grants the role to every workflow run in the repository, on every branch and for every trigger — including a run started from a branch someone pushed just to reach these credentials.
permissions:
contents: read
id-token: write
jobs:
backup:
runs-on: ubuntu-latest
steps:
- name: Exchange the OIDC token for S3 credentials
run: |
TOKEN=$(curl -sS \
-H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
-H "Accept: application/json; api-version=2.0" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=versitygw-ci" | jq -r .value)
aws sts assume-role-with-web-identity \
--endpoint-url https://iam.example.com:7070 --region us-east-1 \
--role-arn arn:aws:iam::000000000000:role/gha-backups \
--role-session-name "gha-${GITHUB_RUN_ID}" \
--web-identity-token "$TOKEN" \
--query Credentials --output jsonExport the returned AccessKeyId, SecretAccessKey and SessionToken as AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN for the steps that follow, masking the last two with ::add-mask::. To confirm the exchange before wiring up any real S3 work, run sts get-caller-identity against the IAM endpoint; it should print arn:aws:sts::000000000000:assumed-role/gha-backups/gha-<run id>.
| Symptom | Cause | Fix |
|---|---|---|
OpenIdIdpCommunicationError: Could not connect to https://… on create |
The issuer is unreachable, resolves to a blocked address, or its certificate does not verify against the system trust store | For an intended internal IdP, enable --oidc-allow-private-endpoints; if it uses an internal CA, also supply ThumbprintList explicitly. Do not use insecure transport merely to bypass a certificate error |
ValidationError: … Value at 'thumbprintList' … Member must not be null |
--disable-oidc-thumbprint-autofetch is set and ThumbprintList was omitted |
Compute the thumbprint with openssl and pass it, or unset the flag |
MalformedPolicyDocument: The trust policy trusts shared OpenID Connect provider … without a Condition scoping … |
The provider is on the shared-issuer list and the trust policy has no positive StringEquals/StringLike/StringEqualsIgnoreCase on its tenancy claim |
Add the condition. A StringLike value of only wildcards does not count |
AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity |
The role does not exist, the RoleArn does not match the role's stored ARN exactly (a path difference counts), no federated principal resolves to a registered provider, or a Deny matched |
Compare RoleArn against GetRole's output character for character; confirm the provider ARN in the trust policy still exists with ListOpenIDConnectProviders
|
InvalidIdentityToken: The web identity token provided could not be validated… |
Issuer mismatch, condition failure, audience not in ClientIDList, or a bad signature |
Decode the token and compare iss to the registered URL exactly (scheme, case and trailing slash all matter), and the effective audience to ClientIDList
|
InvalidIdentityToken: Couldn't retrieve verification key… |
The discovery document or JWKS could not be fetched: DNS, egress blocking, a blocked private address, a disallowed URL scheme, issuer mismatch, TLS/thumbprint failure, too many redirects, a response over 1 MiB, or over 100 keys of one type | Fetch the discovery URL from the IAM host and compare issuer exactly, including scheme and port. For an intended internal IdP, verify the endpoint-policy flags and egress policy; keep TLS verification enabled whenever possible |
InvalidAction: Could not find operation … for version … |
Wrong Version: IAM actions need 2010-05-08, STS actions need 2011-06-15
|
Use an SDK, or set Version correctly. A missing Version is reported as NO_VERSION_SPECIFIED
|
InvalidAccessKeyId from the S3 gateway with an ASIA… key |
No session token was sent | Set AWS_SESSION_TOKEN, and include X-Amz-Security-Token in SignedHeaders for header auth |
InvalidToken: The provided token is malformed or otherwise invalid. (HTTP 400) |
The session token is wrong, or the session expired | Assume the role again |
InvalidToken for every request made with an ASIA… key |
The S3 gateway is running a built-in IAM backend, not the standalone service — no other backend can mint or resolve a session | Session credentials require --iam-standalone-endpoint; see Standalone IAM Setup
|
| A session authenticates but every action is denied | The role was deleted, or deleted and recreated with a new role ID | Recreate the role and assume it again — old sessions never adopt the new role |
Home · Quickstart · Configuration · Operations · Testing · Developer Guide · GitHub · Discussions · Issues
Apache 2.0 · @versitysoftware · LinkedIn · X · Facebook · Instagram
- Home
- Key Features
- User Guide
- Getting Started
- Networking and Deployment
- Access Control / IAM
- Features
- Backends
- Compatibility
- Operations
- Metrics
- Admin APIs
- Logging
- S3 RDMA
- Developer Guide
- Articles