OpenID Connect (OIDC) — The Identity Layer on Top of OAuth
In the OAuth article, we saw how OAuth 2.0 enables a third-party app to access your Google Photos without needing your password. That's authorization — granting access to resources.
But here's the thing. OAuth 2.0 was never designed to tell an application who you are. It was designed to tell an application what it can access. These are fundamentally different things.
When you click "Sign in with Google," the application needs to know your identity — your name, email, user ID. OAuth alone doesn't provide that in a standardized way. Developers hacked around this by using the access token to call a user profile API, but that was never part of the OAuth spec. Different providers did it differently. It was messy and insecure.
OpenID Connect (OIDC) fixes this. It's a thin identity layer built on top of OAuth 2.0 that standardizes how applications verify who a user is.
One way to think about it:
OAuth tells the app what you CAN DO. OIDC tells the app WHO YOU ARE.
OIDC Terminology
Before we dive into flows, let's get the vocabulary straight. OIDC uses slightly different names than plain OAuth:
| OIDC Term | OAuth Equivalent | Meaning |
|---|---|---|
| OpenID Provider (OP) | Authorization Server | The IdP that authenticates users (Google, Okta, WSO2 IS) |
| Relying Party (RP) | Client | The application that relies on the OP for authentication |
| End-User | Resource Owner | The human being authenticated |
| ID Token | (no equivalent) | A JWT containing identity claims — OIDC's key artifact |
| UserInfo Endpoint | (no equivalent) | An API endpoint that returns additional user claims |
| Claims | (no equivalent) | Key-value pairs about the user (name, email, sub) |
The critical addition that OIDC brings to OAuth is the ID Token. Let's examine it.
The ID Token — OIDC's Key Artifact
The ID Token is a JWT that contains claims about the authentication event and the user. It's issued by the OpenID Provider and consumed by the Relying Party.
Here's a decoded ID Token:
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"aud": "my-app-client-id.apps.googleusercontent.com",
"exp": 1716242622,
"iat": 1716239022,
"nonce": "abc123random",
"auth_time": 1716239000,
"at_hash": "HK6E_P6Dh8Y93mRNtsDB1Q",
"name": "Thilan Fernando",
"email": "[email protected]",
"email_verified": true,
"picture": "https://lh3.googleusercontent.com/a/photo.jpg"
}
Let's break down each claim:
| Claim | Required? | Purpose |
|---|---|---|
iss |
Yes | Issuer — who issued this token (must match your configured OP) |
sub |
Yes | Subject — unique user identifier at the OP (never changes) |
aud |
Yes | Audience — your application's client_id (prevents token substitution) |
exp |
Yes | Expiration time (Unix timestamp) |
iat |
Yes | Issued at time |
nonce |
Conditional | Random value you sent in the auth request — prevents replay attacks |
auth_time |
Optional | When the user actually authenticated (not when the token was issued) |
at_hash |
Optional | Hash of the access token — binds the ID token to a specific access token |
acr |
Optional | Authentication Context Reference — how strong the authentication was |
amr |
Optional | Authentication Methods References — what methods were used (password, otp, etc.) |
name, email, etc. |
Optional | User profile claims — returned based on requested scopes |
ID Token vs Access Token — Critical Distinction
This is one of the most common mistakes developers make, so let me be very clear:
| ID Token | Access Token | |
|---|---|---|
| Purpose | Proves who the user is | Authorizes access to APIs |
| Audience | The client application (RP) | The resource server (API) |
| Sent to APIs? | NEVER | Yes — in the Authorization header |
| Format | Always a JWT | JWT or opaque (depends on provider) |
| Contains | User identity claims | Scopes and permissions |
The ID Token is for the client. The access token is for the API. If you're sending your ID Token to an API as a Bearer token — you're doing it wrong.
OIDC Flows
OIDC inherits its flows from OAuth 2.0 but adds the openid scope and returns an ID Token alongside the access token.
Authorization Code Flow (Recommended for Server-Side Apps)
This is the most secure flow and works for any application with a backend.
Browser Your App (RP) Google (OP)
| | |
1. | --- Click Login ----> | |
| | |
2. | | --- Auth Request ----> |
| | (with scope=openid) |
| | |
3. | <--- Redirect to Google Login Page ---------- |
| | |
4. | --- User logs in with Google --------------> |
| | |
5. | <--- Redirect back with auth code ----------- |
| (code=xyz123) | |
| | |
6. | --- Forward code --> | |
| | --- Exchange code ---> |
| | (back-channel POST) |
| | |
7. | | <--- Tokens ---------- |
| | (id_token + access_token + refresh_token)
| | |
8. | | Validate ID Token |
| | Create session |
| | |
9. | <--- Logged in! ---- | |
Step 2 — The Authorization Request:
GET https://accounts.google.com/o/oauth2/v2/auth?
response_type=code
&client_id=my-app-client-id.apps.googleusercontent.com
&redirect_uri=https://myapp.com/callback
&scope=openid profile email
&state=random-csrf-token
&nonce=random-replay-prevention
The key additions for OIDC:
scope=openid— this is what makes it an OIDC request (vs plain OAuth)nonce— a random value that will be embedded in the ID Token to prevent replay attacksstate— CSRF protection (also used in plain OAuth)
Step 6-7 — Token Exchange (Back-Channel):
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=xyz123
&client_id=my-app-client-id.apps.googleusercontent.com
&client_secret=your-client-secret
&redirect_uri=https://myapp.com/callback
Response:
{
"access_token": "ya29.a0ARrdaM...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "1//06vXYZ...",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ijg..."
}
There it is — the id_token. That's what OIDC adds to the OAuth flow.
Authorization Code Flow with PKCE (For SPAs and Mobile Apps)
Public clients (SPAs, mobile apps) can't securely store a client_secret. PKCE (Proof Key for Code Exchange, pronounced "pixy") solves this.
The idea: the client generates a random secret for each request and proves it knows it during the code exchange — without ever exposing a long-lived secret.
1. Client generates a random code_verifier (43-128 characters)
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
2. Client computes code_challenge = SHA256(code_verifier), base64url-encoded
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
3. Auth request includes code_challenge:
GET /authorize?
response_type=code
&client_id=...
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&scope=openid profile email
...
4. Token exchange includes code_verifier:
POST /token
grant_type=authorization_code
&code=xyz123
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
5. Server computes SHA256(code_verifier) and compares with stored code_challenge
If they match → issue tokens
Even if an attacker intercepts the authorization code, they can't exchange it because they don't know the code_verifier. This is now the recommended flow for all public clients.
Implicit Flow (Deprecated)
The Implicit Flow returned tokens directly in the URL fragment (#id_token=...). This was designed for SPAs before PKCE existed.
It's deprecated because:
- Tokens are exposed in the browser URL and history
- No refresh tokens (the spec forbids it for implicit)
- Vulnerable to token leakage through referrer headers
Use Authorization Code + PKCE instead. Always.
Flow Comparison
| Flow | Client Type | Returns | Secure? | Use Today? |
|---|---|---|---|---|
| Authorization Code | Server-side web apps | Code → exchange for tokens | Yes | Yes |
| Authorization Code + PKCE | SPAs, mobile apps, any public client | Code → exchange for tokens (with PKCE proof) | Yes | Yes |
| Implicit | SPAs (legacy) | Tokens directly in URL | No | Deprecated |
| Hybrid | Advanced use cases | ID Token + code from auth endpoint | Yes (if needed) | Rarely |
OIDC Discovery
One of OIDC's most useful features is automatic configuration. Every OIDC provider publishes a discovery document at a well-known URL:
GET https://accounts.google.com/.well-known/openid-configuration
Response (abbreviated):
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"scopes_supported": ["openid", "profile", "email", "address", "phone"],
"response_types_supported": ["code", "token", "id_token", "code token", "code id_token"],
"id_token_signing_alg_values_supported": ["RS256"],
"subject_types_supported": ["public"],
"claims_supported": ["sub", "name", "given_name", "family_name", "picture", "email", "email_verified", "locale"]
}
This document tells your application everything it needs to know:
- Where to send users for authentication (
authorization_endpoint) - Where to exchange codes for tokens (
token_endpoint) - Where to get public keys for signature verification (
jwks_uri) - What scopes and claims are available
Most OIDC client libraries use this endpoint for automatic configuration — you just provide the issuer URL, and the library discovers everything else.
Scopes and Claims
OIDC defines standard scopes that control what user information is returned:
| Scope | Claims Returned |
|---|---|
openid |
sub (required — makes it an OIDC request) |
profile |
name, family_name, given_name, middle_name, nickname, picture, updated_at |
email |
email, email_verified |
address |
address (structured object with street, city, etc.) |
phone |
phone_number, phone_number_verified |
When your application requests scope=openid profile email, you'll get claims from all three scopes in the ID Token or from the UserInfo endpoint.
GET /authorize?
scope=openid profile email
&...
The claims can appear in the ID Token itself, or be available via the UserInfo endpoint. Where they appear depends on the provider and the response type.
UserInfo Endpoint
The UserInfo endpoint provides additional claims about the authenticated user:
GET https://openidconnect.googleapis.com/v1/userinfo
Authorization: Bearer ya29.a0ARrdaM...
Response:
{
"sub": "110169484474386276334",
"name": "Thilan Fernando",
"given_name": "Thilan",
"family_name": "Fernando",
"picture": "https://lh3.googleusercontent.com/a/photo.jpg",
"email": "[email protected]",
"email_verified": true
}
When to use UserInfo vs the ID Token:
- If the claims you need are already in the ID Token — just read them from there (no extra network call)
- If you need claims not included in the ID Token — call the UserInfo endpoint
- If you need the most up-to-date user information — call UserInfo (the ID Token is a snapshot from authentication time)
Implementing "Sign in with Google"
Let's put it all together with a concrete example.
1. Register Your App
Go to the Google Cloud Console, create an OAuth 2.0 client, and configure:
- Authorized redirect URI:
https://myapp.com/callback
You'll get a client_id and client_secret.
2. Redirect the User
<a href="https://accounts.google.com/o/oauth2/v2/auth?
response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://myapp.com/callback
&scope=openid profile email
&state=random-csrf-token
&nonce=random-nonce-value">
Sign in with Google
</a>
3. Handle the Callback
Google redirects back to your callback with an authorization code:
GET https://myapp.com/callback?code=4/0AY0e-g...&state=random-csrf-token
Verify that state matches what you sent (CSRF protection).
4. Exchange the Code
import requests
response = requests.post('https://oauth2.googleapis.com/token', data={
'grant_type': 'authorization_code',
'code': '4/0AY0e-g...',
'client_id': 'YOUR_CLIENT_ID',
'client_secret': 'YOUR_CLIENT_SECRET',
'redirect_uri': 'https://myapp.com/callback'
})
tokens = response.json()
id_token = tokens['id_token']
access_token = tokens['access_token']
5. Validate the ID Token
This is the critical step. You must validate:
import jwt
import requests
# Fetch Google's public keys
jwks = requests.get('https://www.googleapis.com/oauth2/v3/certs').json()
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(jwks['keys'][0])
# Verify the ID Token
claims = jwt.decode(
id_token,
public_key,
algorithms=['RS256'],
audience='YOUR_CLIENT_ID',
issuer='https://accounts.google.com'
)
# Verify nonce matches
assert claims['nonce'] == 'random-nonce-value'
# User is authenticated
print(f"Welcome, {claims['name']}!")
print(f"Email: {claims['email']}")
print(f"User ID: {claims['sub']}")
Validation checklist:
- Verify the signature using the OP's public keys (from JWKS)
- Check
issmatches the expected issuer - Check
audmatches yourclient_id - Check
exphasn't passed - Check
noncematches what you sent in the auth request - Check
iatisn't too far in the past
OIDC Security Considerations
Nonce — Preventing Replay Attacks
The nonce is a random value generated by the client for each authentication request. It's included in the auth request and embedded in the resulting ID Token. When the client receives the ID Token, it checks that the nonce matches. If an attacker replays an old ID Token, the nonce won't match, and the token is rejected.
Audience Validation — Preventing Token Substitution
If you don't check aud, an attacker could take an ID Token issued for a different application and use it with yours. Always verify that aud matches your client_id.
Don't Use Access Tokens for Identity
Access tokens are for APIs. They may not contain user identity claims, they may be opaque, and they're not bound to your application. If you need to know who the user is — use the ID Token.
Open Redirect via redirect_uri
If the OP doesn't strictly validate the redirect_uri, an attacker could manipulate it to steal the authorization code:
GET /authorize?
redirect_uri=https://attacker.com/steal
&...
Defense: The OP must enforce exact match on redirect URIs. No wildcards, no open patterns. Register every valid redirect URI explicitly.
PKCE is Mandatory for Public Clients
If you're building a SPA or mobile app and not using PKCE — stop and add it. Without PKCE, the authorization code can be intercepted and exchanged by an attacker.
OIDC vs SAML — When to Use What
| Aspect | OIDC | SAML 2.0 |
|---|---|---|
| Token format | JSON (JWT) | XML |
| Transport | HTTP redirects + back-channel | HTTP redirects + POST |
| Complexity | Simpler | More complex (XML signatures, schemas) |
| Mobile support | Excellent | Poor (not designed for mobile) |
| Token size | Small (~1KB) | Large (~5-20KB) |
| Adoption | Modern apps, APIs, SPAs | Enterprise legacy, corporate SSO |
| Best for | New applications, consumer-facing | Existing enterprise integrations |
If you're building something new — use OIDC. If you're integrating with an enterprise that requires SAML — you'll need SAML. Many organizations use both — OIDC for modern apps and SAML for legacy systems.
We'll cover SAML in depth in the next article.
Final Thoughts
OIDC is elegant in its simplicity — it takes OAuth 2.0, adds the openid scope, the ID Token, the UserInfo endpoint, and the discovery document, and suddenly you have a complete identity protocol. No more hacking OAuth for login. No more proprietary user profile APIs. Just a standardized way to answer the question: who is this user?
The key takeaways:
- OIDC is OAuth 2.0 + identity. It's not a separate protocol — it's a layer on top.
- The ID Token is for the client application. The access token is for APIs. Don't mix them up.
- Always validate ID Tokens: signature, issuer, audience, expiration, nonce.
- Use Authorization Code + PKCE for all new applications. Implicit flow is dead.
- The discovery document (
.well-known/openid-configuration) makes integration painless.
Next up — SAML 2.0, the enterprise SSO standard that OIDC is gradually replacing but hasn't killed yet.
Stay secure!