Deep Dive · SP ↔ IdP API Architecture
OAuth 2.0 & OpenID Connect
Full Authentication Flow
Every actor, every HTTP call, every token, and every security mechanism — from the first click to silent token refresh.
Overview
Most developers use OAuth without fully understanding what’s happening under the hood. They plug in a library, get a token back, and move on — until a security bug appears, tokens expire at odd times, or an interview question leaves them blank.
This article walks through every single actor, every API call, every token, and every security mechanism in the OAuth 2.0 + OpenID Connect flow.
Key distinction
OAuth 2.0 is NOT an authentication protocol. It is an authorization framework — it answers “can application X access resource Y on behalf of user Z?” It does not tell you who the user is. OpenID Connect (OIDC) is a thin identity layer built on top of OAuth 2.0 that adds the concept of identity via a cryptographically signed ID token.
The Four Actors
Every arrow in this flow is a communication between two of these four actors:
The Five Phases
Authorization Request + PKCE
The user has landed on your app and clicked “Sign in with Google” (or your IdP of choice). What happens next is a carefully orchestrated sequence of redirects and cryptographic operations — before a single credential has been entered.
The browser sends a request to your SP — your application. Simple.
Before doing anything else, your SP generates two values:
- code_verifier — a cryptographically random string, 43–128 characters, high entropy, completely unpredictable.
- code_challenge — derived from the verifier by hashing it with SHA-256 and encoding as Base64URL.
code_challenge = BASE64URL(SHA256(code_verifier))
Why PKCE?
PKCE (Proof Key for Code Exchange) solves a critical problem: in the OAuth flow, the SP asks the IdP for an authorization code that travels back through the browser URL. If an attacker intercepts that code, without PKCE they can exchange it for a full access token. With PKCE, they’re stuck — to exchange the code they need the original code_verifier, which was never sent over the network. SHA-256 is a one-way function; the verifier cannot be derived from the challenge.
The redirect points to the IdP’s /authorize endpoint with these query parameters:
response_type=code
client_id=YOUR_APP_ID
redirect_uri=https://yourapp.com/callback
scope=openid profile email
state=RANDOM_CSRF_VALUE
nonce=RANDOM_REPLAY_VALUE
code_challenge=BASE64URL_HASH
code_challenge_method=S256
The openid scope is what turns this into an OIDC request. state is CSRF protection. nonce prevents ID token replay attacks.
The browser makes a GET request to the IdP’s /authorize endpoint. The user is now on the IdP’s domain — not yours. Your app never sees the user’s password.
User Authentication & Consent
Everything in this phase happens on the IdP’s domain. Your SP has no visibility into it — intentionally.
The IdP renders an HTML login form on its own domain. Your SP cannot intercept the password. The trust is entirely in the IdP.
A POST request goes to the IdP with the username and password.
This internal step can include: verifying the password hash, triggering MFA (TOTP, push notification, hardware key), checking device trust policies, evaluating risk signals (unusual location, new device), and verifying account status. All of this must pass before proceeding.
The IdP shows the user exactly what scopes the application is requesting: “Application X wants access to your name, email, and profile picture.” For enterprise apps with admin pre-authorization, this step is skipped. For consumer OAuth, the user must explicitly approve.
The user clicks Allow.
The IdP generates a short-lived, single-use authorization code — valid for ~10 minutes, usable exactly once. Think of it as a claim ticket at a coat check. It’s not the coat; it’s the ticket to get the coat. The IdP sends a 302 redirect to your SP’s redirect_uri:
https://yourapp.com/callback?code=AUTH_CODE&state=xyz
The code traveled through the browser (visible in the URL). That’s fine — it’s worthless without the code_verifier, and it expires in 10 minutes.
Token Exchange (Back-Channel)
This is arguably the most important phase from a security standpoint. Welcome to the back-channel — server-to-server communication that never touches the browser.
The browser follows the redirect and hits your SP’s callback URL with code and state in the query string. Your SP now has the authorization code.
Before doing ANYTHING with the code, your SP must verify that the state value matches what it generated in Step 3. This is CSRF protection. If an attacker tried to trick your app into making a forged OAuth request, the state wouldn’t match.
Critical
If state doesn’t match — abort immediately. Return an error. Do not proceed under any circumstances.
This does NOT go through the browser. Your SP makes a direct HTTPS POST to the IdP:
POST /token HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&client_id=YOUR_APP_ID
&client_secret=YOUR_APP_SECRET
&redirect_uri=https://yourapp.com/callback
&code_verifier=ORIGINAL_RANDOM_STRING
The code_verifier is being sent for the first time here — it has never appeared in a browser URL. It went from your SP’s memory directly to the IdP’s server over HTTPS.
The IdP takes the code_verifier you sent, runs it through SHA256(), Base64URL-encodes it, and compares it against the stored code_challenge from Step 3:
SHA256(code_verifier) == stored_code_challenge ?
If yes — proceed. If no — reject (stolen code attempt). The IdP also verifies the code hasn’t expired, hasn’t already been used, and that the client_id and redirect_uri match.
If all checks pass, the IdP returns a JSON object with three tokens:
{
"access_token": "eyJ...", // Short-lived JWT (5–60 min)
"id_token": "eyJ...", // OIDC identity JWT
"refresh_token": "dGhp...", // Long-lived, for silent renewal
"token_type": "Bearer",
"expires_in": 3600
}
These tokens never touched the browser. They went directly from IdP to SP, server-to-server.
Token Validation & UserInfo
Having a token isn’t enough. You must validate it. An unvalidated token is worse than no token — it’s a false sense of security.
The id_token is a JWT with three Base64URL-encoded parts separated by dots: header, payload, signature. Your SP must verify, in order:
- Signature — verified with the IdP’s public key. Tampered? Reject.
- iss (issuer) — must match the IdP’s URL.
- aud (audience) — must include your SP’s
client_id. - exp (expiry) — current time must be before
exp. - iat (issued at) — must be in the past, not the future.
- nonce — must match the nonce sent in Step 3. Prevents replay attacks.
- at_hash — hash of the
access_token, if present.
Never do this
Never accept alg: "none". There is a well-known attack where an attacker strips the signature and sets the algorithm to “none,” hoping the library accepts it. Always enforce RS256 or ES256 explicitly in your JWT validation configuration.
To verify the signature, your SP fetches the IdP’s public keys from its JWKS (JSON Web Key Set) endpoint — typically at /.well-known/jwks.json. The kid field in the JWT header identifies which key to use. These keys are cached; you don’t refetch on every login, only when keys rotate.
Your SP may call the IdP’s /userinfo endpoint with the access_token as a Bearer token to receive richer profile claims: full name, email, profile picture, locale, and custom attributes. The ID token has a fixed size limit; /userinfo is the channel for extended data.
GET /userinfo HTTP/1.1
Authorization: Bearer {access_token}
Your SP now has everything it needs. It creates a server-side session or issues its own application-level tokens (e.g., a session cookie). The user is redirected to their destination. The entire OAuth/OIDC dance is invisible to them — they simply “logged in.”
API Access & Token Refresh
Steady state — the ongoing lifecycle after login. Every user action in your app triggers this phase.
The user clicks something — views their profile, loads a dashboard. The browser sends a request to your SP.
Your SP attaches the access_token as a Bearer token and calls the protected API:
GET /api/resource HTTP/1.1
Authorization: Bearer {access_token}
Because JWTs are self-contained, the Resource Server can validate the access_token without calling the IdP. It verifies the JWT signature with the cached public key, checks exp, confirms aud matches its own identifier, and checks scope claims match what this endpoint requires. No network call to the IdP — this is why JWTs are so powerful in microservice architectures.
All checks pass — 200 OK, here’s your data.
When the SP gets a 401 or detects the access token is near expiry, it POSTs to the IdP’s /token endpoint silently:
grant_type=refresh_token
&refresh_token={stored_refresh_token}
&client_id=YOUR_APP_ID
&client_secret=YOUR_APP_SECRET
The IdP returns a new access_token and a NEW refresh_token. The old refresh token is immediately invalidated.
Refresh Token Rotation
Each refresh token use issues a replacement. If an attacker steals a refresh token and tries to use it after you already have — the IdP detects that a spent token is being replayed. This is a theft signal. The IdP can revoke the entire token family, log the user out, and alert the security team. The refresh token is a shared secret; the moment a second party uses it, the alarm goes off.
What triggers full re-authentication?
Refresh tokens can expire (typically 24 hours to 90 days) or be revoked by: the user logging out, an admin revoking the session, or a security policy trigger (password changed, suspicious location). When the refresh token is gone, the user goes through the full Phase 1–4 flow again.
Summary
SP generates PKCE params and redirects to /authorize. No credentials yet — just cryptographic groundwork.
User authenticates at the IdP. MFA runs here. User consents to scopes. IdP issues a short-lived authorization code.
Back-channel token exchange. SP sends code + code_verifier to /token. IdP verifies PKCE and issues all three tokens.
SP validates ID token — signature, issuer, audience, expiry, nonce. Calls /userinfo. Establishes the session.
Steady state. Every API call carries a Bearer access token. Resource Server validates locally. When expired, SP refreshes silently — with rotation providing automatic theft detection.
Security properties baked into every step
- PKCE — stolen authorization codes cannot be exchanged for tokens
- state — prevents CSRF attacks on the OAuth flow
- nonce — prevents ID token replay attacks across sessions
- Back-channel exchange — final tokens never touch the browser
- JWT local validation — no central auth bottleneck at the API layer
- Refresh token rotation — automatic detection of session theft
The bigger picture
This is not just a login system. It is a carefully engineered trust delegation protocol. Every parameter has a reason. Every redirect serves a purpose. Every hash and signature protects against a specific real-world attack.
Quick Reference Cheat Sheet
| Term | What it is |
|---|---|
| SP | Service Provider — your application |
| IdP | Identity Provider — Google, Okta, Azure AD, etc. |
| OAuth 2.0 | Authorization framework — what can you access? |
| OIDC | OpenID Connect — who are you? (built on OAuth 2.0) |
| code_verifier | Random secret generated by SP — never sent through browser |
| code_challenge | SHA256(code_verifier) — sent to IdP in /authorize |
| authorization code | One-time, short-lived ticket from IdP to SP (~10 min) |
| access_token | JWT used to call protected APIs (5–60 min) |
| id_token | JWT containing user identity claims (OIDC-specific) |
| refresh_token | Long-lived credential to get new access tokens silently |
| PKCE | Proof Key for Code Exchange — prevents code interception |
| state | CSRF protection parameter generated by the SP |
| nonce | Replay attack prevention for ID tokens |
| JWKS | JSON Web Key Set — IdP’s public keys for signature verification |
| back-channel | Server-to-server communication (not through browser) |
| front-channel | Communication via browser redirects (visible in URL) |
| Bearer token | Authorization: Bearer {token} HTTP header pattern |
| RT rotation | Each refresh token use issues a new one; old is immediately invalidated |


Leave a Reply