Back to glossary

OpenID Connect (OIDC)

OpenID Connect (OIDC) is an identity layer on OAuth 2.0 in which a provider issues a signed ID token stating who authenticated, for which app, and until when.

Also known as OpenID Connect 1.0, OIDC token

OpenID Connect (OIDC) is an identity layer on top of OAuth 2.0. An identity provider authenticates a person or a workload and issues an ID token: a signed JSON Web Token whose claims state who authenticated, for which application, and until when. Applications verify that token instead of handling passwords or storing long-lived secrets.

OIDC is best known as the protocol behind "Sign in with Google." It now authenticates machines too. A GitHub Actions job can request an OIDC token and trade it for AWS credentials, a PyPI or npm publish token, or a Sigstore signing certificate, with no secret stored anywhere. The most important fact about OIDC is easy to miss: a valid signature proves only who issued a token. Whether the token should be accepted is decided by which claims the relying party checks. The OIDC failures we see in practice are relying parties checking too few.

Our position: OIDC is the right replacement for long-lived CI secrets, and registries and clouds are right to push everyone onto it. But an OIDC trust policy is an authorization rule written in claims, and it deserves the review you give firewall rules. Match immutable IDs rather than names, bind trust to a protected environment, and grant id-token: write to one job. Read an OIDC-backed signature as proof of where code was built, never of what it does.

What is OIDC?

#

OIDC is an authentication protocol: it tells an application who the user is. OAuth 2.0, which OIDC extends, is an authorization protocol. OAuth gives a client an access token for an API and deliberately says nothing about who is at the keyboard. The OpenID Foundation finalized OpenID Connect Core 1.0 in February 2014. The current text incorporates errata set 2, dated December 15, 2023, and the specification family was published as ISO/IEC standards in October 2024, with Core as ISO/IEC 26131:2024. The specification's abstract is still the cleanest definition: "OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 protocol."

OIDC names three parties. The OpenID Provider (OP), usually called the identity provider (IdP), authenticates the subject and issues tokens. Okta, Microsoft Entra ID, and Google are OPs for people, and GitHub runs one for Actions jobs. The relying party (RP) is the application that accepts the token. The subject is the person or workload the token describes. Single sign-on (SSO) is what users experience when many RPs trust the same IdP: one login, and every application accepts the result.

The artifact OIDC adds to OAuth is the ID token. An access token is addressed to an API. An ID token is addressed to the RP itself, through its aud claim, and states facts about one authentication event. OIDC also defines the openid scope that requests an ID token, a UserInfo endpoint for profile claims, and discovery. Treating an OAuth access token as proof of identity is a classic login bug, and the ID token exists to end it.

Discovery and JWKS: verification without a shared secret

#

An OIDC provider publishes its configuration as JSON at a fixed path under its issuer URL. GitHub's Actions provider serves this document, trimmed here to the fields a relying party needs first:

JSON
{
  "issuer": "https://token.actions.githubusercontent.com",
  "jwks_uri": "https://token.actions.githubusercontent.com/.well-known/jwks",
  "response_types_supported": ["id_token"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "claims_supported": ["sub", "aud", "exp", "iat", "iss", "jti", "nbf", "ref",
    "repository", "repository_id", "repository_owner", "repository_owner_id",
    "environment", "job_workflow_ref", "runner_environment", "..."]
}

A relying party needs nothing from GitHub except the issuer URL. It reads jwks_uri, downloads the JSON Web Key Set (JWKS), caches the public keys by key ID (kid), and can then verify any token the provider signs. No secret is ever shared between the provider and the relying party. That property lets one provider be trusted by thousands of relying parties it has never heard of. It also means the relying party alone decides what a valid token is allowed to do.

How does OIDC work?

#

OIDC works by redirecting the user to the identity provider, which authenticates them and returns a signed ID token to the application through the OAuth authorization code flow. For a person signing in to a web app, the steps are:

  1. The RP redirects the browser to the OP's authorization endpoint with scope=openid, its client_id, a redirect_uri, a random state, a random nonce, and a PKCE code_challenge.
  2. The OP authenticates the user, including any multi-factor authentication, and asks for consent if needed.
  3. The OP redirects the browser back to the redirect_uri with a single-use authorization code.
  4. The RP sends the code and its PKCE verifier to the OP's token endpoint over a direct TLS connection.
  5. The token endpoint returns an id_token, usually alongside an access_token.
  6. The RP validates the ID token and starts a session for the sub it names.

An ID token is three base64url segments joined by dots: header, payload, signature. This is the example payload from section 2 of OIDC Core:

JSON
{
  "iss": "https://server.example.com",
  "sub": "24400320",
  "aud": "s6BhdRkqt3",
  "nonce": "n-0S6_WzA2Mj",
  "exp": 1311281970,
  "iat": 1311280970,
  "auth_time": 1311280969,
  "acr": "urn:mace:incommon:iap:silver"
}

Every ID token must carry iss, sub, aud, exp, and iat. The sub claim is the subject's stable identifier at that issuer. Relying parties should key accounts on the pair of iss and sub, never on an email address, which can change hands. The nonce claim binds the token to the login request that asked for it.

What a relying party must check

#

Section 3.1.3.7 of OIDC Core lists thirteen ID token validation steps. Together with key discovery, seven checks do nearly all the security work, and they fall into two groups.

The figure shows an ID token, three segments labeled header, payload, and signature, arriving at a relying party that runs seven checks in two groups. The first group proves who issued the token: (1) discover the provider's configuration at {iss}/.well-known/openid-configuration and read jwks_uri; (2) select the JWKS key whose kid matches the token header; (3) verify the signature with an algorithm on an allowlist, such as RS256, and never none. The second group decides whether to accept the token: (4) iss must exactly equal the expected issuer; (5) aud must contain the relying party's own client ID or audience; (6) the token must be fresh, with exp in the future, iat recent, and its nonce or jti not seen before; (7) the subject claims, such as sub, repository_owner_id, and environment, must match the relying party's trust policy. A note under the figure says that every failure in this article involved a token that passed checks 1 to 3.

The second group is where implementations go wrong, because skipping a check there still produces a working login. The audience check is the clearest case. OIDC Core is explicit: "The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience." An RP that skips it will accept a token the same IdP issued to any other application, including one an attacker registered. The signature check has its own trap: let the token header choose the algorithm and a forged token can claim none, which OIDC Core forbids for ID tokens returned from the authorization endpoint.

OIDC vs SAML vs OAuth

#

OIDC, SAML 2.0, and OAuth 2.0 are often listed as alternatives, but only two of them authenticate anyone. SAML (Security Assertion Markup Language) is the older enterprise SSO standard, ratified at OASIS in 2005, and does the same job as OIDC with signed XML assertions instead of JSON Web Tokens.

OAuth 2.0OIDCSAML 2.0
Question it answersWhat may this client do?Who authenticated, for which app?Who authenticated, for which app?
TokenAccess token, often opaqueID token: signed JWTAssertion: signed XML
Key distributionNot specified for bearer tokensJWKS via discovery URLCertificates exchanged in metadata
TransportRedirects plus REST back channelRedirects plus REST back channelBrowser POST of an XML form
Machine identityClient credentials with a shared secretNative: CI and cloud workload tokensNot used in practice
Typical useAPI delegationNew web and mobile login, CI/CDEnterprise SSO into SaaS

Verdict: use OIDC for every new login integration and for all workload identity. Accept SAML where an enterprise IdP or a customer contract requires it, and keep its XML signature library patched. XML signature verification keeps producing critical bugs: CVE-2024-45409 in ruby-saml, published September 10, 2024 and rated critical, let anyone holding one signed SAML document log in as any user. Never use bare OAuth for login.

Neither OIDC nor SAML manages accounts. Just-in-time (JIT) provisioning creates a local account the first time a valid token or assertion arrives, filled from its claims. It never removes one. SCIM (System for Cross-domain Identity Management, RFC 7644, September 2015) is the protocol that lets an IdP create, update, and deactivate accounts inside each application. Without SCIM, disabling someone at the IdP blocks new logins but leaves open sessions and app-local credentials alive, including any API tokens that person created.

How do CI systems use OIDC instead of stored secrets?

#

CI systems use OIDC by running an identity provider for their own jobs: each job can request a short-lived ID token that describes the run, and a cloud or registry exchanges it for a scoped credential. On GitHub Actions a job opts in with permissions: id-token: write. The runner then exposes two environment variables, and any code in the job can mint a token for an audience it chooses:

Bash
curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
  "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value

The token's claims describe the run: repository, ref, sha, event_name, job_workflow_ref, environment, runner_environment, and the numeric repository_id and repository_owner_id. The sub claim packs the most important ones into one string. Per GitHub's OIDC reference, a job that references an environment gets repo:ORG/REPO:environment:NAME. A pull request run without an environment gets repo:ORG/REPO:pull_request, and other runs get repo:ORG/REPO:ref:refs/heads/BRANCH or refs/tags/TAG. Environment-scoped trust is the strongest default because environments can require reviewers and restrict which branches deploy.

Relying parties on the other side include AWS STS through AssumeRoleWithWebIdentity, Google Cloud Workload Identity Federation, Azure, Sigstore's Fulcio certificate authority, and every major package registry. PyPI introduced trusted publishing in April 2023, RubyGems in December 2023, crates.io in July 2025, and npm in July 2025. The model now carries real volume: about a quarter of all files uploaded to PyPI in October 2025 came through trusted publishing, from more than 45,000 projects. npm trusted publishing covers the registry side in depth.

The relying party's whole decision lives in its trust policy. This is a minimal AWS role trust policy for one repository's production environment:

JSON
{
  "Effect": "Allow",
  "Principal": {
    "Federated": "arn:aws:iam::111122223333: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:acme/api:environment:production"
    }
  }
}

Everything that makes this policy safe is in the Condition block. Delete the sub line and the policy is still valid JSON, still deploys, and accepts a correctly signed token from any repository on GitHub. GitHub's reference warns that you "must define at least one condition, so that untrusted repositories can't request access tokens for your cloud resources."

The classic sub contains only names, and names can be reused. GitHub introduced an immutable subject format on April 23, 2026 that embeds the owner's and repository's numeric IDs, as in repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main. Repositories created after July 15, 2026 get the immutable format automatically, as do repositories renamed or transferred after that date. Older repositories keep the name-only format until someone opts in. Trust policies have to cover both forms during the transition, and a wildcard such as repo:acme/* does not match the new one, because a / no longer follows the org name.

What an OIDC token leaves behind in package provenance

#

An OIDC token lives for minutes, but its claims can outlive it in public. When npm generates provenance for a trusted publish, the CI job sends a second OIDC token, with audience sigstore, to Fulcio. Fulcio verifies the token and copies its claims into X.509 extensions of a short-lived signing certificate, under the OID arc 1.3.6.1.4.1.57264.1 documented in Fulcio's OID reference. The provenance signature made with that certificate is recorded in the Rekor transparency log. Anyone can read those claims back from the npm registry. This script fetches the attestations for pnpm 12.16.1 and prints the OIDC-derived fields:

Python
# pip install cryptography
import base64, json, sys, urllib.request
from cryptography import x509

pkg = sys.argv[1] if len(sys.argv) > 1 else "pnpm@12.16.1"
url = f"https://registry.npmjs.org/-/npm/v1/attestations/{pkg}"
doc = json.load(urllib.request.urlopen(url))

NAMES = {"8": "Issuer", "12": "Source Repository URI", "15": "Source Repository Identifier",
         "17": "Source Repository Owner Identifier", "18": "Build Config URI",
         "20": "Build Trigger", "23": "Deployment Environment", "24": "Token Subject"}

for att in doc["attestations"]:
    cert = att["bundle"]["verificationMaterial"].get("certificate")
    if not cert:
        continue  # npm's own publish attestation is signed with a registry key
    c = x509.load_der_x509_certificate(base64.b64decode(cert["rawBytes"]))
    print("valid:", c.not_valid_before_utc, "->", c.not_valid_after_utc)
    for ext in c.extensions:
        arc = ext.oid.dotted_string.removeprefix("1.3.6.1.4.1.57264.1.")
        if arc in NAMES:
            raw = ext.value.value          # DER UTF8String: tag, length, value
            n = raw[1]
            start = 2 if n < 0x80 else 2 + (n & 0x7F)
            print(f"{NAMES[arc]:35} {raw[start:].decode()}")

We ran it on November 5, 2026:

Plain text
valid: 2026-10-27 14:56:02+00:00 -> 2026-10-27 15:06:02+00:00
Issuer                              https://token.actions.githubusercontent.com
Source Repository URI               https://github.com/pnpm/pnpm
Source Repository Identifier        50565430
Source Repository Owner Identifier  21320719
Build Config URI                    https://github.com/pnpm/pnpm/.github/workflows/release.yml@refs/tags/v12.16.1
Build Trigger                       push
Deployment Environment              release
Token Subject                       repo:pnpm/pnpm:environment:release

Three things stand out. The signing certificate was valid for exactly ten minutes. The Token Subject extension holds the raw sub claim, and it is the name-only environment form, because pnpm's repository predates the immutable default. And Fulcio still recorded the immutable repository ID and owner ID from the token's other claims. A verifier that pins those two numbers is immune to name reuse, which is the same check PyPI applies on the publishing side. This is what Sigstore keyless signing actually is: an OIDC identity, frozen into a certificate and a transparency log.

None of these fields describe what the tarball contains. In May 2026 the TanStack compromise shipped 84 malicious versions with valid provenance, because the attacker's code ran inside TanStack's own release job. Every one of them added a 2.3 MB obfuscated router_init.js file, which content analysis sees and a provenance check does not. Socket's AI Scanner flagged all 84 artifacts within six minutes of publication. Package provenance answered which pipeline published the code. Only reading the code answered whether it was safe.

Where does OIDC trust break?

#

OIDC trust breaks at the relying party's policy, not at the signature. Every case below involved a token that GitHub legitimately signed.

The relying party never checks the subject

#

The commonest failure is a trust policy that checks the audience and nothing else. In April 2023, Tinder's security team reported AWS roles, including some used in AWS's own repositories, that external repositories could assume because the trust policy did not check sub. In July 2023, Datadog Security Labs collected more than 500 role ARNs from over 275 AWS accounts, mostly from public workflow files, and assumed a number of vulnerable roles. One belonged to the UK Government Digital Service and could push to every CodeCommit repository in its account. The cause there was subtle: a Terraform policy repeated the StringEquals key, and in HCL the last duplicate key wins, so the sub check silently disappeared. Datadog's update says AWS began blocking creation of roles with such trust policies in June 2025. Roles that already existed were not changed, so the only fix for them is an audit.

Trust keyed to names that can be re-registered

#

A name-based sub breaks when the name changes hands. If an organization renames or deletes itself, anyone can claim the freed name, create a repository and workflow with matching names, and mint tokens whose sub is byte-for-byte identical. This is repojacking applied to identity. PyPI designed against it from the start: its trusted publishing internals say "PyPI always checks the repository_owner_id claim" against the ID stored when the publisher was configured.

GitHub's immutable subject was meant to give every relying party that protection, and its first version shows how hard string grammars are. The original format joined name and ID with a hyphen, which is legal inside GitHub names. A researcher at Boost Security Labs showed that an attacker could register a legacy organization literally named <target>-<target_id>, whose old-format tokens collide with the victim's new-format subject. The write-up puts it plainly: "Both JWTs are signed by GitHub. Both strings match." GitHub disabled the opt-in about an hour after the April 24, 2026 report and reissued it with @ as the delimiter, a character GitHub names cannot contain. The lesson generalizes: a string comparison is only as unambiguous as the grammar of the string, and numeric ID claims compare unambiguously.

Replay inside the validity window

#

An OIDC token is a bearer token until it expires, so relying parties that mint credentials from it need replay protection. PyPI's 2026 security audit by Trail of Bits found a gap in exactly that. PyPI recorded each token's jti claim in Redis until five seconds after exp, but its JWT library accepted tokens up to 30 seconds past expiration. That left a 25-second window in which a stolen GitHub Actions token could be replayed repeatedly, each time minting a fresh 15-minute upload token. A related race let two concurrent requests with the same jti both succeed. PyPI fixed both. Clock-skew leeway and replay caches are each reasonable on their own. The bug lived in the gap between them.

Code inside the job mints its own token

#

The TanStack compromise needed no misconfigured policy at all. Attacker code reached TanStack's release job through a poisoned GitHub Actions cache, read the job's OIDC token from the runner process's memory, and exchanged it for npm publish tokens. The result was 84 malicious versions across 42 packages, part of the Mini Shai-Hulud campaign. Every check in the figure passed, because the token was real and the job was the configured one. An OIDC grant belongs to the whole job, not to the step that was meant to use it, which makes GitHub Actions security part of every OIDC trust decision.

What a sound OIDC trust policy checks

#

Each control below narrows what a valid token can do. None of them makes the job that holds the token trustworthy.

Pin the audience exactly. A token minted for one relying party then cannot be replayed against another. It does nothing about other jobs that request the same audience.

Pin the subject to a protected environment, not to a branch or an organization wildcard. Environment protection rules put a reviewer or a branch restriction in front of the token. They do not stop code that runs inside an approved job.

Match immutable IDs. Use the @-delimited subject format, or a relying party that checks repository_owner_id and repository_id directly. This closes name reuse. It does nothing if the legitimate repository itself is compromised.

Give id-token: write to one small job that installs no dependencies, restores no cache, and runs no project code. This is the direct fix for the TanStack chain. It does not protect the artifact that a compromised build job hands to the publish job.

Audit roles that predate the platform guardrails. Per Datadog's update, AWS now blocks creation of roles with the vulnerable pattern, but it left existing roles alone. Your oldest roles are the ones written before anyone knew to check.

OIDC removed the stored secret, and that was the right trade. It did not remove authorization. It moved it into claim-matching rules that live in a cloud console or a registry settings page, that are rarely tested, and that fail open when a line is missing. The token also carries no identity finer than the job, so no relying party can tell a publish step from a malicious test helper running beside it. Until CI providers can bind a token to a single step and relying parties compare IDs instead of names by default, an OIDC token answers which pipeline is asking. Whether the code that pipeline ships is safe remains a question only the package contents can answer.