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.
| Value | Created by | Purpose | Checked where |
|---|---|---|---|
state | RP, per login attempt | Binds the callback to the browser session that started it (CSRF) | RP at callback |
code_verifier | RP, per attempt | Proves the token request comes from whoever started the flow (PKCE) | OP at token endpoint |
nonce | RP, per attempt | Binds the ID token to this attempt; stops replay and injection | RP after token exchange |
iss in the response | OP | Says which provider issued the code; stops mix-up | RP 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
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_endpointOne 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:
- Issuer.
issmust exactly equal the issuer from the provider's discovery document. No trailing-slash tolerance, no prefix matching. - Audience.
audmust contain yourclient_id, and the spec says to reject tokens whose audience lists parties you do not trust. - Authorised party. The current errata text of OIDC Core leaves
azpto 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 expectazpon multi-audience tokens. Checking both, as the code below does, is cheap defence in depth against accepting a token minted for another client. - 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.
- Expiry and issue time. Reject expired tokens and, optionally, tokens issued too long ago, with a small clock-skew allowance.
- Nonce. If you sent a nonce, and you always should, the claim must be present and equal to the stored value.
- Authentication age. If you sent
max_age,auth_timemust 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, aneventsclaim with the memberhttp://schemas.openid.net/event/backchannel-logout, and asubor asid. It must not contain a nonce, which prevents it from being accepted as an ID token, and providers are recommended to type it aslogout+jwt. The RP answers 200 on success and 400 on failure. Validate it like an ID token, reject replayedjtivalues, then delete every session matching thesid, or all of the user's sessions if onlysubis 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
algfrom the token enablesnoneand key-confusion attacks; pin it. - Unbounded JWKS refetch. Refetching on every unknown
kidwithout 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
| Choice | Gain | Cost |
|---|---|---|
| Certified client library | Spec coverage, maintained fixes | Less control; still configure every check |
| Hand-rolled flow | Full understanding, small footprint | You own every edge case above |
| Short RP sessions | Revocation takes effect sooner | More silent re-authentication traffic |
| Back-channel logout | Reliable single logout | Session store indexed by sid; inbound endpoint |
| Verify signature in code flow | Defence in depth | JWKS fetch and caching to operate |
What to do next
- Pull your provider's discovery document and confirm the issuer string, algorithms and whether it returns
issin authorization responses. - Check that each login attempt creates fresh state, nonce and PKCE verifier, and that state is single-use.
- Write a test per validation rule above: wrong nonce, audience, azp, issuer, expiry, auth_time and at_hash.
- Key users by issuer and subject, never by email, and migrate any existing email-keyed accounts.
- Implement JWKS refetch on unknown kid with a rate limit, and rehearse a key rotation in staging.
- Choose a logout variant, preferably back-channel, and make sessions findable by provider session id.
- Read OAuth 2.0 in depth for refresh tokens and sender-constrained tokens.