OpenID Connect: ID Tokens, UserInfo, and Discovery
Explain OpenID Connect as an identity layer built on top of OAuth 2.0 -- ID tokens vs access tokens, the UserInfo endpoint, nonce-based replay protection, issuer/audience validation, and the discovery document -- and implement ID token verification in PHP.
OAuth 2.0, by itself, is an authorization protocol: it answers 'can this app access this data on the user's behalf?'. It was never designed to answer 'who is this user?' -- and plenty of early 'Sign in with X' integrations bolted authentication onto OAuth 2.0 in ad hoc, insecure ways because of that gap. OpenID Connect (OIDC) is a thin, standardized identity layer built directly on top of OAuth 2.0 that closes this gap properly: it adds a new, purpose-built token (the ID token) that asserts who the user is, a dedicated endpoint for fetching more profile details, and a discovery mechanism so clients don't need any of an authorization server's endpoints hardcoded. This lesson covers each of those additions and how to verify an ID token correctly.
OIDC as an Identity Layer on Top of OAuth 2.0
OIDC reuses the exact OAuth 2.0 Authorization Code flow from the previous lesson -- same /authorize redirect, same code exchange at /token, same PKCE -- with one addition: the client includes openid in the requested scope, and in response, the token endpoint returns not just an access_token but also an id_token. That single added scope value is what turns a plain OAuth flow into an OIDC authentication flow.
The critical distinction to internalize is: an access token is an opaque-to-the-client credential for calling an API -- the client typically shouldn't even try to inspect its contents, since its format is between the authorization server and resource server. An ID token, by contrast, is ALWAYS a JWT (see the next lesson for JWT structure in depth) with a defined set of claims specifically about the authenticated user's identity -- who they are, when they authenticated, which client this assertion is for -- and it is meant to be read and validated by the client itself, not sent onward to any API.
Sending an access token to a third API as proof of identity, or sending an ID token to a resource server as an access credential, are both misuses of the two token types -- they serve different purposes: the ID token proves identity to the CLIENT; the access token proves authorization to the RESOURCE SERVER.
• OIDC = OAuth 2.0 + the 'openid' scope + a new id_token in the token response. • Access token: opaque credential for calling APIs -- authorization, for the resource server. • ID token: always a JWT, contains identity claims about the user -- authentication, for the client. • Never send an ID token to a resource server as an API credential, and never treat an access token as proof of identity.
The ID Token and the UserInfo Endpoint
The ID token itself carries a baseline set of identity claims directly in its signed payload -- at minimum sub (subject: a stable, unique identifier for the user), iss (issuer), aud (audience), exp, iat, and typically auth_time (when the user actually authenticated). Because it's signed and self-contained, the client can validate it and read these claims without any further network call -- which is exactly what makes OIDC lightweight enough for the client to establish 'who is this user' immediately after the token exchange, no extra request needed.
For richer profile data that the client didn't need baked into every token (name, picture, locale, verified email address, and so on), OIDC defines a /userinfo endpoint: the client calls it with the access_token (not the ID token) as a bearer credential, and gets back a JSON object with whatever additional claims the granted scopes entitle it to (profile, email, etc. are standard OIDC scope values that each unlock a defined bundle of claims). This two-tier design -- lean, self-contained ID token plus an on-demand UserInfo call for the rest -- keeps ID tokens small while still letting a client request as much profile detail as it actually needs.
A client typically only needs to call UserInfo when it wants claims beyond what's already in the ID token, or wants the freshest possible profile data (the ID token's claims are a snapshot from the moment it was issued; UserInfo reflects the current state).
• ID token: signed, self-contained, includes sub/iss/aud/exp/iat -- validated locally, no extra network call. • UserInfo endpoint: called with the access_token, returns additional profile claims per the granted scopes. • Standard OIDC scopes (profile, email, ...) each unlock a defined bundle of UserInfo claims. • Use UserInfo when you need claims beyond the ID token, or the freshest current profile data.
Nonce: Preventing Replay of ID Tokens
OAuth's state parameter protects the redirect/callback step from CSRF, as covered in the previous lesson -- but OIDC adds a second, distinct value specifically for the ID token itself: nonce. The client generates a random nonce before starting the flow, sends it in the initial /authorize request, and the authorization server is REQUIRED to embed that exact value as a claim inside the returned ID token. When the client receives the ID token, it checks that the nonce claim inside it matches the value the client itself generated for this specific flow.
This defends against replay: without a nonce, if an attacker somehow obtained a previously-issued, still-valid ID token (for example, one leaked via a referrer header, logs, or a compromised network path), they could potentially inject it into a victim's browser session to have the victim's client accept it as if it were the result of the victim's own fresh login -- an ID token injection / replay attack. Because the nonce is tied to one specific flow instance and the client checks it was echoed back correctly, a token from a different flow (a different nonce) is rejected even if its signature and every other claim are otherwise valid.
state and nonce solve adjacent but different problems: state protects the OAuth callback redirect from CSRF; nonce protects the ID token itself from being replayed into a different session than the one that requested it. A correct OIDC client implementation always generates and checks both.
• nonce: a random value generated by the client, sent in /authorize, echoed back as a claim inside the ID token. • The client checks the ID token's nonce claim matches the value it generated for THIS flow instance. • Defends against replaying a previously-issued, still-valid ID token into a different login session. • state (CSRF on the callback) and nonce (replay protection on the ID token) are both required, and solve different problems.
Issuer and Audience Validation
Verifying an ID token's cryptographic signature only proves it was issued by SOME key the client trusts -- it does not by itself prove the token was issued by the RIGHT authorization server, or that it was issued FOR this specific client. Two more claims close that gap. iss (issuer) identifies which authorization server issued the token -- the client must check it exactly matches the expected issuer URL (e.g. https://accounts.example.com), because a client that trusts multiple issuers' signing keys (or fetches keys dynamically) could otherwise be tricked into accepting a token from an issuer it didn't intend to trust for this login.
aud (audience) identifies which client(s) the token was issued for -- the client must check its OWN client_id appears in the aud claim. Skipping this check opens a serious cross-client vulnerability: if Authorization Server X issues ID tokens to both Client A and Client B, and Client A never checks aud, then a malicious Client B could take a legitimate ID token issued to one of its own users and replay it against Client A's login endpoint -- the signature is genuinely valid (same issuer signed it), but it was never meant to authenticate that user to Client A.
Both checks are mandatory on every ID token verification, alongside signature and expiry: issuer must exactly equal the expected authorization server, and the client's own client_id must appear in the audience. A verification routine that checks the signature and expiry but skips iss/aud is only partially validating the token.
• iss (issuer): must exactly match the expected authorization server's issuer URL. • aud (audience): must contain the verifying client's own client_id. • Skipping aud validation lets a token legitimately issued to a DIFFERENT client be replayed against this one. • Signature + expiry + issuer + audience are all mandatory checks -- none of them alone is sufficient.
Discovery: .well-known/openid-configuration
Rather than every client hardcoding an authorization server's /authorize, /token, /userinfo, and signing-key endpoints (and having to update them if the provider ever changes its infrastructure), OIDC defines a discovery document at a well-known, predictable URL: {issuer}/.well-known/openid-configuration. A GET request there returns a JSON document listing every endpoint the client needs -- authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri (where to fetch the current signing keys), supported scopes, supported signing algorithms, and more.
The jwks_uri in particular matters for the key-management concerns covered in the next lesson: it points to a JSON Web Key Set (JWKS) -- the set of public keys currently valid for verifying ID token signatures. A client fetches and caches this, and refreshes it periodically (or when it encounters a kid -- key ID -- in a token header it doesn't recognize), which is exactly how providers rotate their signing keys without breaking every client that integrates with them: publish the new key in the JWKS ahead of time, start signing new tokens with it, and clients that fetch a fresh JWKS pick it up automatically.
Discovery means integrating a new OIDC provider is often just 'give the client this one issuer URL' -- every other endpoint and capability is derived automatically from that single well-known document, rather than requiring a developer to manually copy five different URLs out of the provider's documentation.
• Discovery document: GET {issuer}/.well-known/openid-configuration -> JSON of every relevant endpoint. • Includes authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, supported scopes/algorithms. • jwks_uri: the current public signing keys, used to verify ID token signatures -- fetched and cached by the client. • Discovery lets a provider rotate infrastructure/keys and clients pick up the change automatically, without hardcoded URLs.
Requesting an ID token: adding openid scope and a nonce
'openid' in the scope list is what tells the authorization server to include an id_token in the /token response. 'profile' and 'email' are standard OIDC scopes that unlock corresponding claim bundles, either in the ID token or via UserInfo. nonce is generated and stored the same way state is, but checked against a claim INSIDE the ID token, not against a query parameter.
<?php
declare(strict_types=1);
session_start();
$nonce = bin2hex(random_bytes(16));
$_SESSION['oidc_nonce'] = $nonce;
$authorizeUrl = 'https://accounts.example.com/authorize?' . http_build_query([
'response_type' => 'code',
'client_id' => 'my-client-id',
'redirect_uri' => 'https://myapp.example.com/oidc/callback',
'scope' => 'openid profile email', // "openid" is what makes this OIDC, not plain OAuth
'state' => bin2hex(random_bytes(16)),
'nonce' => $nonce,
]);Code Examples
One issuer URL yields every endpoint the client needs, fetched from the provider's own discovery document rather than hardcoded. The discovery document's own 'issuer' field is cross-checked against the URL used to fetch it, so a client can't be silently pointed at a different provider's endpoints. Expected result: https://accounts.example.com/oauth2/token https://accounts.example.com/.well-known/jwks.json if (($this->config['issuer'] ?? null) !== $issuer) { throw new RuntimeException(...); }
Verification is layered: a bad signature, wrong issuer, wrong audience, mismatched nonce, or expiry each fail independently and explicitly, rather than a single vague 'invalid token' -- which matters for debugging real integration issues. The example token's aud is deliberately 'other-client' to show the audience check catching a token that was genuinely signed correctly but issued for someone else. Expected result: Token audience does not include this client.
if (!in_array($this->expectedAudience, $audience, strict: true)) { throw ...; }
Common mistake: Verifying only the signature and expiry, and skipping iss/aud checks, means a validly-signed ID token issued to a completely different client (or even a different, untrusted issuer the app happens to also have keys for) can be accepted as proof of identity here.
<?php
declare(strict_types=1);
final class OidcProviderConfig
{
private array $config;
public function __construct(private readonly string $issuer)
{
$discoveryUrl = rtrim($issuer, '/') . '/.well-known/openid-configuration';
$ch = curl_init($discoveryUrl);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || $body === false) {
throw new RuntimeException("Failed to fetch OIDC discovery document from {$discoveryUrl}");
}
$this->config = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
// The discovery document's own "issuer" field must match what we
// expected to fetch -- otherwise we could be talking to the wrong
// provider entirely (e.g. a misconfigured or spoofed URL).
if (($this->config['issuer'] ?? null) !== $issuer) {
throw new RuntimeException('Discovery document issuer mismatch.');
}
}
public function authorizationEndpoint(): string { return $this->config['authorizati
…<?php
declare(strict_types=1);
final class IdTokenValidationException extends RuntimeException {}
final class IdTokenVerifier
{
public function __construct(
private readonly string $expectedIssuer,
private readonly string $expectedAudience, // our own client_id
) {}
/**
* @param callable $verifySignature (string $header, string $payload, string $signature) -> bool
* In production this delegates to a vetted JWT/JWK library that fetches
* jwks_uri and checks the signature against the current public key.
*/
public function verify(string $idToken, string $expectedNonce, callable $verifySignature): array
{
$parts = explode('.', $idToken);
if (count($parts) !== 3) {
throw new IdTokenValidationException('Malformed ID token.');
}
[$header, $payload, $signature] = $parts;
if (!$verifySignature($header, $payload, $signature)) {
throw new IdTokenValidationException('ID token signature is invalid.');
}
$claims = json_decode(
base64_decode(strtr($payload, '-_', '+/')),
true,
flags: JSON_TH
…ID token vs access token vs UserInfo
The ID token proves identity to the client; the access token authorizes API calls; UserInfo supplements the ID token's claims on demand.
/token response
+--------------------------------------------+
| access_token -> opaque, for the |
| RESOURCE SERVER (APIs) |
| id_token -> JWT, for the CLIENT itself |
| (sub, iss, aud, nonce, |
| exp, iat, auth_time) |
+--------------------------------------------+
| |
| access_token | id_token
v v
+---------------+ +------------------------+
| GET /userinfo | | Client verifies: |
| (more profile | | signature, iss, aud, |
| claims, per | | nonce, exp |
| granted scopes) | | -> establishes identity |
+---------------+ +------------------------+Important Rules
Include 'openid' in the requested scope to get an id_token back -- that's what distinguishes an OIDC flow from plain OAuth.
Never use an access token as proof of identity, and never send an ID token to a resource server as an API credential.
Generate a nonce per flow, send it in /authorize, and verify it matches the ID token's nonce claim -- distinct from the state check.
Always validate iss (exact match) and aud (contains this client's client_id) on every ID token, alongside signature and expiry.
Prefer the discovery document ({issuer}/.well-known/openid-configuration) over hardcoding a provider's endpoints.
Where OIDC Shows Up
Every modern 'Sign in with Google/Microsoft/Apple' button is OIDC, not plain OAuth -- that's precisely why those flows return an identity (your email, name, profile picture) rather than just an opaque access token. Enterprise single sign-on products (Okta, Auth0, Azure AD / Entra ID, Keycloak) are OIDC providers first and foremost, and PHP OIDC client libraries like league/oauth2-client (with a provider extension) or jumbojett/openid-connect-php implement exactly the verification steps in this lesson -- discovery, nonce, issuer, and audience -- so most PHP apps integrate via a vetted library rather than hand-rolling the verifier shown here.
// after verifying the id_token's signature and exp only: $userId = $claims['sub']; // treated as authenticated, no further checks if ($claims['iss'] !== $expectedIssuer || !in_array($myClientId, (array)$claims['aud'], true) || !hash_equals($expectedNonce, $claims['nonce'] ?? '')) { throw new IdTokenValidationException(); } $userId = $claims['sub'];
A signature check alone only proves SOME trusted key signed the token -- not that it was issued by the expected provider, for this specific client, or in response to this specific login attempt. Skipping iss/aud/nonce validation allows a genuinely well-signed token meant for a different client or a different flow to be accepted here.
What does OpenID Connect add on top of plain OAuth 2.0? A. A faster token exchange protocol B. An identity layer -- a standardized ID token, UserInfo endpoint, and discovery, so clients can reliably learn WHO the user is, not just what they're authorized to access C. Mandatory two-factor authentication D. A replacement for the Authorization Code flow
B. An identity layer -- a standardized ID token, UserInfo endpoint, and discovery, so clients can reliably learn WHO the user is, not just what they're authorized to access — OIDC reuses OAuth 2.0's flows unchanged and adds a purpose-built identity assertion (the ID token), a way to fetch more profile data (UserInfo), and a way to discover a provider's endpoints -- none of which OAuth 2.0 alone defines.
Why does OIDC define a separate 'nonce' parameter in addition to OAuth's 'state'? A. nonce and state are two names for the exact same protection B. nonce is embedded inside the ID token itself and defends against replaying a previously-issued token into a different login session; state protects the callback redirect from CSRF C. nonce replaces the need for PKCE D. nonce is only used for refresh tokens
B. nonce is embedded inside the ID token itself and defends against replaying a previously-issued token into a different login session; state protects the callback redirect from CSRF — state and nonce protect different things: state guards the OAuth callback redirect against CSRF, while nonce is checked as a claim inside the returned ID token to prevent that specific token from being replayed into an unrelated session.
A client verifies an ID token's signature and expiry but skips checking the 'aud' claim. What does this allow? A. Nothing -- signature and expiry checks are sufficient on their own B. A validly-signed ID token issued by the same authorization server, but intended for a DIFFERENT client, to be accepted here as proof of identity C. The access token to expire early D. The discovery document to become invalid
B. A validly-signed ID token issued by the same authorization server, but intended for a DIFFERENT client, to be accepted here as proof of identity — The audience claim identifies which client(s) a token was issued for; skipping that check lets a genuinely valid token meant for a different client be replayed against this one, since the signature alone doesn't encode intended recipient.
What does the OIDC discovery document ({issuer}/.well-known/openid-configuration) provide? A. The user's current access token B. A JSON document listing the provider's endpoints (authorization, token, userinfo, jwks_uri) and supported capabilities, so clients don't need them hardcoded C. A list of every user who has logged in D. The client's registered redirect URIs
B. A JSON document listing the provider's endpoints (authorization, token, userinfo, jwks_uri) and supported capabilities, so clients don't need them hardcoded — Discovery lets a client derive every endpoint it needs -- including where to fetch current signing keys via jwks_uri -- from a single issuer URL, and lets the provider change or rotate that infrastructure without breaking integrated clients.
Modify the IdTokenVerifier::verify() method to also accept a token whose aud claim is a single string (not an array) -- some providers issue a plain string when there's only one audience, per the OIDC spec, rather than always wrapping it in a JSON array.
PHP's (array) cast on a scalar string produces a single-element array, e.g. (array) 'my-client-id' === ['my-client-id'], so the existing in_array($this->expectedAudience, (array) ($claims['aud'] ?? []), strict: true) already handles both the array-of-strings and bare-string forms of aud without any code change.
$claims['aud'] can be a string in the raw JSON, e.g. "aud": "my-client-id" instead of "aud": ["my-client-id"]. The verify() method already does (array) $claims['aud'] before the in_array check -- confirm that cast turns a bare string into a single-element array so the same in_array check keeps working.