OIDC and Secretless Pipeline Authentication
The oldest way for a pipeline to deploy to a cloud is to store a long-lived cloud key — an AWS access-key pair, a GCP service-account JSON, an Azure client secret — as a CI secret and hand it to the deploy job. Every such key is a permanent, exfiltratable credential sitting in the highest-value automation an organization runs. Secretless pipeline authentication eliminates it. Instead of storing a key, the pipeline platform is itself an OpenID Connect (OIDC) identity provider: at run time the runner requests a freshly-signed OIDC identity token (a JSON Web Token, or JWT) describing exactly which repository, branch, and job is running, presents it to the cloud provider, and the cloud provider — having been pre-configured to trust tokens shaped like that — exchanges it for short-lived credentials valid only for the duration of the job (GitHub, About security hardening with OpenID Connect). No durable key is ever stored, so there is nothing to leak, rotate, or steal. This note owns the authentication machinery; the complementary control of scoping what a job can do once authenticated is Least-Privilege Pipeline Runners, and the general enterprise pattern is Federation and Single Sign-On in the Cloud.
Mental Model — Prove Who You Are, Don’t Carry a Password
The shift is from bearer secret to verifiable identity. A stored key is a bearer secret: whoever holds the bytes is the principal, forever, until someone notices and rotates it. An OIDC token is a proof of identity — a short-lived, cryptographically-signed statement “this is repo X’s main branch, running job Y, right now” that the cloud verifies against the pipeline platform’s public keys and its own trust rules. The cloud never trusts a secret you copied in; it trusts an assertion it can independently verify and that expires in minutes.
flowchart LR subgraph OLD["Stored-secret model (what we're killing)"] K["Long-lived cloud key<br/>stored as a CI secret"] --> J1["Deploy job carries the key"] J1 --> C1["Cloud: 'you hold the key → you're in'"] K -.->|"leak / exfiltrate / never rotated"| ATT["Attacker has it forever"] end subgraph NEW["OIDC secretless model"] IDP["Pipeline = OIDC provider"] -->|"mints per-job"| TOK["Signed JWT<br/>iss · sub · aud · claims"] TOK --> J2["Deploy job presents the JWT"] J2 --> C2{"Cloud verifies:<br/>signature? trust rule match?"} C2 -->|yes| CRED["Short-lived credential<br/>expires with the job"] end
What it shows and the insight to take: the top row is the failure mode — a durable secret that, once leaked, grants access indefinitely. The bottom row replaces the stored key with a per-job signed assertion the cloud verifies rather than trusts on possession, yielding a credential that dies with the job. The insight: you cannot steal a secret that was never stored, and a credential that expires in minutes is nearly worthless to exfiltrate. This is the same reasoning that makes Least-Privilege Pipeline Runners powerful — remove the durable, high-value thing an attacker is reaching for.
Mechanical Walk-through — The Token Exchange
The flow has four actors: the runner (executing the job), the pipeline platform’s OIDC provider (a signing service exposing public keys at a well-known URL), the cloud provider (holding a pre-configured trust rule), and the target resource. The sequence below traces a GitHub Actions → AWS deployment, but the shape is identical for GitLab, GCP, and Azure — only the names change.
sequenceDiagram autonumber participant Job as Runner (the job) participant IDP as GitHub OIDC provider<br/>token.actions.githubusercontent.com participant AWS as AWS STS participant S3 as AWS resource (e.g. S3) Job->>IDP: request OIDC token (needs `id-token: write`) IDP-->>Job: signed JWT { iss, sub:"repo:org/repo:ref:refs/heads/main", aud:"sts.amazonaws.com", ... } Job->>AWS: AssumeRoleWithWebIdentity(role ARN, JWT) AWS->>IDP: fetch public keys from /.well-known/jwks (verify signature) AWS->>AWS: check JWT claims vs role trust policy<br/>(:aud and :sub conditions) AWS-->>Job: temporary credentials (AccessKeyId, SecretAccessKey, SessionToken) — short-lived Job->>S3: call API with temporary credentials Note over Job,S3: credentials valid only for this job; nothing durable stored
What it shows and the insight to take: step 1–2 mint an identity token in the job; steps 3–5 are the exchange and verification — the cloud pulls the provider’s public keys to confirm the JWT is authentically signed (not forged), then checks the token’s claims against a trust rule the account owner configured in advance; steps 6–7 use the resulting temporary credential. The insight: two independent checks gate access — authenticity (is this a real, unforged GitHub token?) and authorization (does this specific repo/branch match what I decided to trust?). Both must pass, and neither involves a stored secret.
Step 1–2: minting the token
On GitHub Actions the runner “can request a token from GitHub’s OIDC provider,” and this requires the job to hold the id-token: write permission — one of the token scopes covered in Least-Privilege Pipeline Runners (GitHub, About security hardening with OpenID Connect). The issued JWT carries a set of claims that describe the running context, the most important being:
iss(issuer):https://token.actions.githubusercontent.com— identifies GitHub as the signer.sub(subject): the identity string, formatted likerepo:octo-org/octo-repo:environment:prodorrepo:octo-org/octo-repo:ref:refs/heads/main— this is the claim you scope trust against.aud(audience): who the token is for, e.g.sts.amazonaws.com— bounds where the token may be used.- Context claims:
repository,repository_owner,repository_id,ref,sha,run_id,actor,workflow,environment, andjob_workflow_ref, plus lifecycle claimsiat/nbf/exp(GitHub, About security hardening with OpenID Connect).
Step 3–5: the exchange
The runner calls the cloud’s token-exchange endpoint. On AWS this is the STS AssumeRoleWithWebIdentity API, passing the target role’s ARN and the JWT (AWS, Create an OIDC identity provider). The cloud first verifies the signature by fetching the provider’s public keys from its JWKS (JSON Web Key Set) endpoint — the URL AWS records when you register the OIDC identity provider — then evaluates the role’s trust policy against the token’s claims. Only if both succeed does STS return temporary credentials.
Configuration Walk-through — AWS, with Line-by-Line Commentary
Two artifacts are configured once, out-of-band: an IAM OIDC identity provider (telling AWS to trust GitHub’s issuer and validate its signing keys) and a role trust policy (telling AWS which GitHub tokens may assume this role). The IAM OIDC provider is created with the provider URL https://token.actions.githubusercontent.com and audience sts.amazonaws.com (GitHub, Configuring OpenID Connect in AWS).
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:octo-org/octo-repo:ref:refs/heads/main"
}
}
}Line by line: Principal.Federated names the OIDC provider registered above — only tokens from this issuer are candidates. Action is the web-identity assume-role call. The two Condition entries are the security core: :aud must equal sts.amazonaws.com (the token was minted for AWS, not replayable to another cloud), and :sub must exactly equal repo:octo-org/octo-repo:ref:refs/heads/main — so only the main branch of that one repository can assume the role. A token from another repo, another branch, or a fork’s PR has a different sub and is rejected. The workflow side is small:
permissions:
id-token: write # REQUIRED — lets the job request the OIDC JWT
contents: read # for actions/checkout
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: us-east-1
- run: aws s3 sync ./dist s3://my-bucketid-token: write is the non-negotiable line — without it the runner cannot request a JWT at all. The configure-aws-credentials action performs the mint-and-exchange and populates the standard AWS credential environment for subsequent steps. No AWS key appears anywhere in the repo or its secrets (GitHub, Configuring OpenID Connect in AWS).
Uncertain
Verify: GitHub’s immutable subject-claim format for repositories created after a mid-2026 cutover — a fetched doc described a
repo:OWNER@ID/REPO@ID:...subform for repos “created after July 15, 2026.” Reason: single fetch, a very recent and easily-misparsed change; the@IDsuffix and exact date need confirmation. To resolve: re-fetch the current Configuring OpenID Connect in AWS page and GitHub’s OIDC changelog. Trust scoping on the oldrepo:OWNER/REPO:...form is well-established; only the immutable-ID variant is uncertain.#uncertain
Trust Scoping — the Part That Actually Provides Security
OIDC’s danger is that it is easy to configure too broadly. A trust policy that conditions only on :aud (or uses a wildcard :sub like repo:octo-org/*) will accept a token from any repository in the org, any branch — including an attacker’s fork PR if that path can mint a token. The entire security value lives in scoping the subject.
flowchart TD START["Incoming OIDC token"] --> AUD{":aud matches?<br/>e.g. sts.amazonaws.com"} AUD -->|no| REJECT["REJECT"] AUD -->|yes| SUB{":sub matches trust rule?"} SUB -->|"repo:org/repo:ref:refs/heads/main"| TIGHT["✅ ONE repo, ONE branch<br/>(good — minimal scope)"] SUB -->|"repo:org/repo:environment:prod"| ENV["✅ ONE repo, prod env<br/>(good — env-gated)"] SUB -->|"repo:org/repo:pull_request"| PR["⚠️ any PR incl. forks<br/>(dangerous — avoid)"] SUB -->|"repo:org/* (wildcard)"| WIDE["❌ ANY repo in org<br/>(too broad)"] SUB -->|no match| REJECT
What it shows and the insight to take: every token must clear two gates — audience then subject — and the subject condition is where you trade convenience for blast radius. Pinning to ref:refs/heads/main or environment:prod limits which exact workflow can assume the role; a pull_request subject or an org-wide wildcard flings the door open to untrusted forks or unrelated repos. The insight: OIDC removes the stored secret but does not automatically remove over-permissioning — a sloppy :sub condition is just as exploitable as a leaked key. The two claims to always bind are audience (stops the token being replayed to a different cloud) and a tightly-specified subject (stops the wrong repo/branch/PR from assuming the role).
The same principle appears across every cloud, expressed in that cloud’s vocabulary:
| Platform | Identity token concept | Trust anchor object | Where you scope the subject |
|---|---|---|---|
| AWS | JWT via STS AssumeRoleWithWebIdentity | IAM OIDC identity provider + role trust policy | Condition on ...:sub and ...:aud (AWS) |
| GCP | OAuth 2.0 token exchange via Security Token Service | Workload identity pool + provider | attribute condition (a CEL expression) + attribute.*/google.subject mapping (GCP) |
| Azure | External JWT → Microsoft identity platform token | Federated identity credential on an app registration / user-assigned managed identity | issuer, subject, audience must case-sensitively match (Microsoft) |
| GitLab | ID token via id_tokens: keyword | third party’s own trust config | aud per-token + claims (sub, project_id, namespace_id, ref) (GitLab) |
GCP: pools, providers, and CEL attribute conditions
Google models the trust anchor as a workload identity pool (recommended “for each non-Google Cloud environment”) containing a provider describing the external IdP (GCP, Workload Identity Federation). The exchange follows “the OAuth 2.0 token exchange specification”: the Security Token Service “verifies the identity and returns a federated token,” which can then be exchanged for “a short-lived OAuth 2.0 access token.” Scoping is done with attribute mapping (google.subject=assertion.sub, plus up to 50 attribute.NAME values) and an attribute condition — “a CEL expression that can check assertion attributes”; if it evaluates true the credential is accepted, otherwise rejected. Google explicitly frames the condition as the defense that “prevents credentials intended for use with another platform from being used with Google Cloud.” Access can be direct (grant the external identity a role) or via service account impersonation (grant roles/iam.workloadIdentityUser). The stated payoff: it “eliminates the maintenance and security burden associated with service account keys.”
Azure: federated identity credentials
Microsoft Entra attaches a federated identity credential to either a user-assigned managed identity or an app registration, configured to “trust tokens from an external identity provider (IdP), such as GitHub.” At run time “your external software workload exchanges trusted tokens from the external IdP for access tokens from Microsoft identity platform” — using the client-credentials flow but “passing in the identity provider’s JWT instead of creating one yourself using a stored certificate” (Microsoft, Workload identity federation). The critical scoping rule: the credential’s issuer, subject, and audience values “must case-sensitively match the corresponding issuer, subject and audience values contained in the token.” Microsoft is explicit about the goal — you “eliminate the maintenance burden of manually managing credentials and eliminate the risk of leaking secrets or having certificates expire.”
GitLab: ID tokens
GitLab replaced its earlier CI_JOB_JWT/CI_JOB_JWT_V2 variables with the id_tokens: keyword, which mints one or more JWTs per job, each with an explicitly-declared audience (GitLab, ID token authentication):
deploy:
id_tokens:
AWS_TOKEN:
aud: https://sts.amazonaws.com # audience bound per-token
script:
- ./assume-role-with $AWS_TOKENThe token’s sub defaults to project_path:{group}/{project}:ref_type:{type}:ref:{branch_name}, and it carries stable identifiers — project_id, namespace_id, ref, ref_type, ref_protected, environment — that third parties (Vault, AWS, GCP) validate to establish trust “without managing credentials.” GitLab’s own guidance stresses binding on stable numeric identifiers (project_id, namespace_id) rather than mutable path strings, since a project can be renamed or a path re-used.
Failure Modes and Common Misunderstandings
- “OIDC means I don’t need to think about permissions.” False. OIDC removes the stored secret, not the authorization scope. An over-broad trust condition (wildcard
:sub,:aud-only) plus an over-privileged role recreates the full risk. Trust scoping (this note) and least-privilege on the assumed role/token (see Least-Privilege Pipeline Runners) are both required. - Forgetting
id-token: write. On GitHub the job cannot request a JWT without it, and because declaring anypermissions:block drops unlisted scopes tonone, adding OIDC to a locked-down workflow means explicitly re-addingid-token: write(GitHub, Workflow syntax). - Audience confusion / token replay. If the trust rule does not pin
:aud, a token minted for one service could be presented to another. Always bind the audience to the specific consumer. - Binding to mutable subjects. Conditioning on a repo/project path means a rename (or, worse, a deleted-and-re-registered name) can shift who satisfies the rule. Prefer immutable IDs where the platform exposes them (GitLab
project_id; GitHub’s immutable-ID subject form). - JWKS key limits. Both AWS and Azure cap the number of signing keys they will fetch from a provider (AWS: 100 RSA + 100 EC keys; Azure: first 100 keys). A provider exposing more can cause intermittent
InvalidIdentityToken-class failures (AWS; Microsoft).
Alternatives and When to Choose Them
- Stored long-lived keys — the thing OIDC replaces. Justified only where the target genuinely cannot federate (a legacy system with no OIDC trust support). Then compensate with Secret Injection in Pipelines: short rotation, masked logs, environment-scoped secrets, and withholding from untrusted/forked jobs.
- A secrets manager (HashiCorp Vault, cloud secret stores) — often combined with OIDC rather than opposed: the pipeline authenticates to Vault via its ID token (no stored Vault token), and Vault issues short-lived dynamic secrets. This is the GitLab-ID-token-to-Vault pattern. It adds a broker but centralizes policy and audit.
- Cloud-native runners with attached identity — a self-hosted runner inside the cloud can use the platform’s instance/pod identity (e.g. an EC2 instance role, a GKE workload identity) directly, with no token exchange. Cleaner when the runner already lives in the target cloud; OIDC federation is what you reach for when the runner is outside it (the common hosted-runner case).
Production Notes
OIDC federation is now the recommended default across all three major clouds and both major forges precisely because it deletes the highest-frequency supply-chain footgun — a long-lived cloud key sitting in CI. GitHub, GitLab, AWS, GCP, and Microsoft all now document it as the preferred path and each states the same rationale in its own words: no secrets to store, rotate, or leak (GitHub; GCP; Microsoft). The practical adoption checklist: register the OIDC provider once per cloud account; create a role/identity per pipeline with a tightly-scoped subject (specific repo + branch or environment) and a bound audience; grant that role least privilege on the resources it touches; add id-token: write (or the platform equivalent) to only the jobs that deploy; and prefer immutable identifiers in trust conditions. Paired with ephemeral least-privilege runners, OIDC is what makes “the pipeline has production access” a defensible statement rather than a standing liability — the credential is minted per job, scoped to one identity, and gone before it could ever be exfiltrated.
See Also
- Least-Privilege Pipeline Runners — the sibling §7 note: scope what a job can do and destroy the runner after; OIDC removes the durable credential, least-privilege bounds the authority
- Secret Injection in Pipelines — the fallback control for secrets that genuinely must be stored (rotation, masking, environment-scoping, forked-PR withholding)
- Federation and Single Sign-On in the Cloud — the general cloud identity-federation and SSO pattern this specializes for pipelines
- Runners Agents and Executors — the runner that requests the token and performs the exchange
- Continuous Integration and Delivery MOC — parent MOC (§7 Pipeline Security and Reliability)
- DevSecOps and Supply Chain Security MOC — sibling MOC owning the broader supply-chain security controls this authentication hardening composes with