OAuth 2.0 is a framework for delegated authorisation: it lets an application obtain an access token to call an API on a user's behalf. It deliberately says nothing about who the user is. OpenID Connect (OIDC) is the identity layer built on top of it: the same authorization code flow, plus an ID token, a signed JSON Web Token that tells the application which user authenticated, at which provider, when and for which client.

The site already explains the OAuth side in OAuth 2.0 in depth, PKCE in the PKCE walkthrough, and JWT signing and generic verification in JWT in depth. This article is the algorithmic view of OIDC from the relying party's side, meaning the application that wants users to log in. It treats login as a protocol state machine with a small number of values that must be generated, bound, checked and destroyed in the right order, and it covers the parts that only exist in OIDC: the nonce, the authorised party, authentication age, token hashes, discovery, issuer mix-up and logout.

The parties and the four values

Three parties and four values carry the whole protocol. The relying party (RP) is your application. The OpenID provider (OP) authenticates users and issues tokens. The browser carries redirects between them and is not trusted by either side.

ValueCreated byPurposeChecked where
stateRP, per login attemptBinds the callback to the browser session that started it (CSRF)RP at callback
code_verifierRP, per attemptProves the token request comes from whoever started the flow (PKCE)OP at token endpoint
nonceRP, per attemptBinds the ID token to this attempt; stops replay and injectionRP after token exchange
iss in the responseOPSays which provider issued the code; stops mix-upRP at callback

PKCE, defined in RFC 7636, and state look alike but protect different steps: state protects the redirect into your callback, while PKCE protects the code at the token endpoint. The nonce protects the ID token itself, which is why OIDC requires the RP to check it even when the other two are in place. The OAuth 2.0 Security Best Current Practice, RFC 9700, recommends PKCE for all clients, including confidential server-side ones.

The login flow as a state machine

Authorization code flow with PKCE, state and nonce, from the relying party's sideBrowserRelying party (your app)OpenID providerJWKS / discovery1. GET /loginstore state, nonce, code_verifier in session2. 302 to /authorize3. authorize: state, nonce, code_challengeuser authenticates4. 302 back: code, state, iss5. GET /callback: code, statecheck state and iss, then exchange6. POST /token: code, code_verifier7. id_token, access_token8. keys by kid (cached)9. validate ID token: sig, iss, aud, azp, exp, nonce, auth_time10. new session cookieThe browser never sees the code_verifier; the provider never sees the nonce's session binding.
The ten steps of an OIDC login. Steps 1 to 5 run through the browser; 6 to 8 are direct back-channel calls from the RP; 9 is the validation algorithm below.

The flow starts when the RP generates three fresh random values and stores them in a server-side session keyed to the browser, then redirects to the provider's authorization endpoint with response_type=code and a scope including openid. When the user returns, the RP looks up the pending attempt by state, rejects the callback if there is none, and deletes the pending entry immediately so the same state cannot be used twice. It then exchanges the code for tokens over a direct TLS connection, sending the code_verifier, and validates the ID token before creating its own session.

import base64, hashlib, secrets, time

def b64url(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")

def start_login(session: dict, max_age: int | None = None) -> dict:
    state, nonce = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
    verifier = secrets.token_urlsafe(64)
    challenge = b64url(hashlib.sha256(verifier.encode("ascii")).digest())
    session["pending"] = {"state": state, "nonce": nonce, "verifier": verifier,
                          "max_age": max_age, "started": int(time.time())}
    params = {"response_type": "code", "client_id": CLIENT_ID,
              "scope": "openid email", "redirect_uri": REDIRECT_URI,
              "state": state, "nonce": nonce,
              "code_challenge": challenge, "code_challenge_method": "S256"}
    if max_age is not None:
        params["max_age"] = str(max_age)
    return params      # urlencode onto the discovered authorization_endpoint

One pending attempt per session is the simple version. If users open several tabs and log in from more than one, store pending attempts in a small map keyed by state with a short expiry, and remove each entry on first use.

Validating the ID token

OIDC Core section 3.1.3.7 lists the ID token checks. Generic JWT verification, meaning signature, algorithm pinning, expiry and issued-at, is covered in JWT in depth. The OIDC-specific additions are the ones implementations most often skip:

  1. Issuer. iss must exactly equal the issuer from the provider's discovery document. No trailing-slash tolerance, no prefix matching.
  2. Audience. aud must contain your client_id, and the spec says to reject tokens whose audience lists parties you do not trust.
  3. Authorised party. The current errata text of OIDC Core leaves azp to extensions, and says that when it is present the client should verify that its own client id is the value. Older editions told clients to expect azp on multi-audience tokens. Checking both, as the code below does, is cheap defence in depth against accepting a token minted for another client.
  4. Signature and algorithm. Use the algorithm you registered, RS256 by default, and keys from the provider's JWKS. The spec permits relying on the TLS connection to the token endpoint instead of the signature in the code flow, but verifying anyway costs little and survives a later refactor that passes tokens around.
  5. Expiry and issue time. Reject expired tokens and, optionally, tokens issued too long ago, with a small clock-skew allowance.
  6. Nonce. If you sent a nonce, and you always should, the claim must be present and equal to the stored value.
  7. Authentication age. If you sent max_age, auth_time must be present and recent enough. Use this for step-up before sensitive actions.
import jwt   # PyJWT

def validate_id_token(id_token, key, pending, access_token=None):
    claims = jwt.decode(
        id_token, key,
        algorithms=["RS256"],                  # pinned; never taken from the header
        audience=CLIENT_ID, issuer=ISSUER, leeway=60,
        options={"require": ["iss", "sub", "aud", "exp", "iat"]},
    )
    aud = claims["aud"] if isinstance(claims["aud"], list) else [claims["aud"]]
    if len(aud) > 1 and claims.get("azp") != CLIENT_ID:
        raise ValueError("multiple audiences without azp naming this client")
    if "azp" in claims and claims["azp"] != CLIENT_ID:
        raise ValueError("azp names a different client")
    if not secrets.compare_digest(claims.get("nonce", ""), pending["nonce"]):
        raise ValueError("nonce mismatch")
    if pending["max_age"] is not None:
        auth_time = claims.get("auth_time")
        if auth_time is None or time.time() - auth_time > pending["max_age"] + 60:
            raise ValueError("authentication older than max_age")
    if access_token is not None and "at_hash" in claims:
        if claims["at_hash"] != half_hash(access_token):
            raise ValueError("at_hash does not match the access token")
    return claims

def half_hash(value):
    digest = hashlib.sha256(value.encode("ascii")).digest()  # SHA-256 for RS256
    return b64url(digest[: len(digest) // 2])

The at_hash claim is the base64url encoding of the left half of a hash of the access token, using the hash that matches the ID token's algorithm, so SHA-256 for RS256. It is optional in the code flow, where both tokens arrive together over TLS, and becomes important in hybrid flows where tokens travel through the browser; c_hash does the same for the authorization code. When the claim is present, check it. Tested against tokens signed with a throwaway RSA key, this function accepts a correct token and rejects each of a wrong nonce, a foreign audience, two audiences without azp, a stale auth_time, a foreign issuer, an expired token and a swapped access token.

Discovery, key rotation and mix-up

Providers publish their metadata at {issuer}/.well-known/openid-configuration: the issuer string, the authorization, token and userinfo endpoints, the jwks_uri and the supported algorithms. Fetch it once at start-up, check that its issuer field equals the issuer you configured, and cache it.

Keys rotate. Cache the JWKS, select keys by the kid header, and when a token arrives with an unknown kid, refetch the JWKS once, rate-limited, before failing. Without the refetch every login breaks for one cache lifetime after each rotation; without the rate limit, an attacker sending random kid values turns your login endpoint into a traffic generator against the provider.

Mix-up attacks matter as soon as an RP supports more than one provider. An attacker-controlled provider can trick the RP into sending a code issued by an honest provider to the attacker's token endpoint. The defences are to store which provider each attempt went to, alongside state, and to check the iss parameter that RFC 9207 adds to the authorization response when the provider supports it. Many providers advertise support in discovery via authorization_response_iss_parameter_supported.

Sessions and logout

A valid ID token is evidence of an authentication event, not a session. After validation, create your own session keyed by an opaque random id in a HttpOnly, Secure, SameSite cookie, store the sub and iss pair as the user key, and discard the ID token unless you need it for logout. Never key users by email: it can change and, at some providers, is not verified. The pair of issuer and subject is the only stable identifier OIDC guarantees.

Logout has three variants, and they solve different problems:

  • RP-initiated logout. The RP ends its own session, then redirects the browser to the provider's end-session endpoint, typically with the ID token as a hint, so the provider session ends too.
  • Front-channel logout. The provider loads a logout URL for each RP in hidden frames. It depends on third-party cookies, which modern browsers increasingly block, so it is fragile.
  • Back-channel logout. The provider POSTs a signed logout token directly to the RP. The token must contain iss, aud, iat, exp, jti, an events claim with the member http://schemas.openid.net/event/backchannel-logout, and a sub or a sid. It must not contain a nonce, which prevents it from being accepted as an ID token, and providers are recommended to type it as logout+jwt. The RP answers 200 on success and 400 on failure. Validate it like an ID token, reject replayed jti values, then delete every session matching the sid, or all of the user's sessions if only sub is given.

Back-channel logout is the reliable choice for server-side applications, and it requires that your session store can find sessions by provider session id, which is a design decision to make at the start rather than retrofit. For the wider choice between server sessions and self-contained tokens, see JWT versus session tokens.

Worked example: a token minted for the wrong client

A company runs two applications against the same provider tenant: a customer web shop, client id shop-web, and an internal reporting tool, client id reports. A developer of the reporting tool, or malware on a user's machine, holds a valid ID token issued to reports and tries to use it to sign in to the shop by injecting it into the shop's callback handling.

Follow the checks in order. The signature verifies, because both clients trust the same provider keys. The issuer matches. The expiry is fine. The audience check is the first to fail: aud is reports, not shop-web. If your provider issues multi-audience tokens, suppose it had listed both clients in aud with azp set to reports; the audience check would pass, and only the azp check stops it. Finally, even a token correctly addressed to the shop fails, because its nonce belongs to an attempt the shop never started, so no pending entry matches.

The lesson is that every check closes a different door. Libraries that verify the signature and expiry by default and leave audience, azp and nonce to configuration accept this token. Make the rejected-token tests in the checklist below part of your build.

Failure modes

  • Using the access token as proof of login. Access tokens are meant for APIs and may be opaque or minted for another audience. Login comes from the validated ID token.
  • Skipping the nonce because PKCE is on. They protect different artefacts; OIDC requires the nonce check when a nonce was sent.
  • Reusable state. Pending attempts that are not deleted on first use allow a captured callback to be replayed.
  • Lenient issuer matching. Accepting any issuer under a domain, or ignoring the trailing slash, lets a multi-tenant provider's other tenants sign in as your users.
  • Algorithm from the header. Reading alg from the token enables none and key-confusion attacks; pin it.
  • Unbounded JWKS refetch. Refetching on every unknown kid without a rate limit is a denial-of-service lever.
  • Clock skew. Servers with drifting clocks reject fresh tokens intermittently. Run NTP and allow about a minute of leeway, not an hour.

Trade-offs

ChoiceGainCost
Certified client librarySpec coverage, maintained fixesLess control; still configure every check
Hand-rolled flowFull understanding, small footprintYou own every edge case above
Short RP sessionsRevocation takes effect soonerMore silent re-authentication traffic
Back-channel logoutReliable single logoutSession store indexed by sid; inbound endpoint
Verify signature in code flowDefence in depthJWKS fetch and caching to operate

What to do next

  1. Pull your provider's discovery document and confirm the issuer string, algorithms and whether it returns iss in authorization responses.
  2. Check that each login attempt creates fresh state, nonce and PKCE verifier, and that state is single-use.
  3. Write a test per validation rule above: wrong nonce, audience, azp, issuer, expiry, auth_time and at_hash.
  4. Key users by issuer and subject, never by email, and migrate any existing email-keyed accounts.
  5. Implement JWKS refetch on unknown kid with a rate limit, and rehearse a key rotation in staging.
  6. Choose a logout variant, preferably back-channel, and make sessions findable by provider session id.
  7. Read OAuth 2.0 in depth for refresh tokens and sender-constrained tokens.
Key takeaway: OIDC login is a small algorithm over four values: state protects the callback, PKCE protects the code, the nonce protects the ID token and the issuer parameter protects against provider mix-up. Generate them fresh per attempt, check each exactly once, validate every ID token claim the spec lists, key users by issuer and subject, and plan logout from the start.