The moment you outsource who is this user? to PingFederate, Auth0, Okta, or any OpenID Connect provider, you stop writing password code and start writing protocol code. And the protocol is where the subtle mistakes live — not in the crypto, but in which token means what. This is the flow I reach for, and the reasoning behind each move, so I get identity right instead of merely getting it to work.
The mental model: authorization vs. authentication
OAuth 2.0 and OIDC are constantly confused because they ride the same wire. Keep them separate in your head:
- OAuth 2.0 answers “is this client allowed to do X?” It issues an access token — a key to resources (your API). It says nothing, by design, about who the user is.
- OpenID Connect is a thin layer on top that answers “who is this user?”
It adds an ID token — a signed JWT of identity claims — and a
/userinfoendpoint. You opt into it by puttingopenidin yourscope.
Three parties, three tokens:
| Party | Role |
|---|---|
| Client (your app) | Wants to log the user in |
| Authorization Server (PingFederate / Auth0) | Authenticates the user, issues tokens |
| Resource Server (your API) | Accepts the access token to authorize calls |
| Token | What it’s for | Who reads it |
|---|---|---|
| ID token (JWT) | Identity — proves who logged in | Your client |
| Access token | Authorization — a key to an API | Your resource server |
| Refresh token | Getting new tokens without re-login | Your client (kept secret) |
The single most important consequence: the ID token is for your app; the access token is not. Hold that thought — most login bugs are a violation of it.
Why authorization-code + PKCE (and not the shortcuts)
The flow is a redirect dance: your app never sees the user’s password. It
bounces the browser to the IdP, the IdP authenticates and bounces back with a
short-lived, single-use code, and your app trades that code for tokens on a
back channel (server-to-server, or a locked-down fetch).
Why the code, and not just get the token in the redirect? Because the redirect
travels through the browser — the URL lands in history, logs, and Referer
headers. The old implicit flow put the token right there in the URL and is
now deprecated for exactly that reason. The code is useless on its own; it must
be redeemed.
PKCE (Proof Key for Code Exchange, “pixie”) closes the remaining hole: what
if an attacker intercepts the code mid-redirect? PKCE binds the code to the
client instance that started the flow:
- Before redirecting, the client invents a random secret — the
code_verifier. - It sends only a hash of it — the
code_challenge— in the auth request. - At token exchange it presents the raw
code_verifier. The server re-hashes and compares.
A stolen code is worthless without the verifier, which never left the client. PKCE was born for mobile/SPA “public” clients that can’t hold a secret, but it’s now recommended for every client — confidential ones too. Defense in depth costs you nothing here.
The flow, step by step
1. Generate the PKCE pair
code_verifier = 43–128 random chars from [A-Z a-z 0-9 - . _ ~]
code_challenge = BASE64URL( SHA256(code_verifier) )Keep the code_verifier in the user’s session (or SPA memory). You’ll need it
in step 4.
2. Redirect the browser to the authorization endpoint
GET /authorize?
response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://app.example.com/callback
&scope=openid profile email
&state=RANDOM_OPAQUE_VALUE
&nonce=RANDOM_ONE_TIME_VALUE
&code_challenge=BASE64URL_SHA256
&code_challenge_method=S256Every parameter earns its place:
response_type=code— ask for a code, not a token. This is the authorization-code flow.scope=openid …—openidis what turns OAuth into OIDC and gets you an ID token.profile emailrequest those claim sets.state— a random value you stash and re-check on return. It’s your CSRF guard: it proves the callback belongs to this browser’s flow, not one an attacker stitched in.nonce— a one-time value the IdP echoes inside the ID token. You verify it matches. It’s your replay guard against a captured ID token being re-injected.code_challenge/S256— the PKCE hash. Always useS256, neverplain.
state and nonce are not optional decoration. Skipping them is skipping the
two attacks the flow was designed to stop.
3. Handle the callback
The IdP authenticates the user and redirects back:
GET /callback?code=SINGLE_USE_CODE&state=RANDOM_OPAQUE_VALUEVerify state equals what you sent before doing anything else. If it
doesn’t match, abort — this is a forged callback.
4. Exchange the code for tokens (back channel)
A direct POST to the token endpoint — not a redirect:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SINGLE_USE_CODE
&redirect_uri=https://app.example.com/callback
&client_id=YOUR_CLIENT_ID
&code_verifier=THE_RAW_VERIFIER_FROM_STEP_1- Public clients (SPA, native): PKCE (
code_verifier) is the proof. No secret. - Confidential clients (a backend): also authenticate —
client_secret(viaclient_secret_basic/client_secret_post) or, better,private_key_jwt. PKCE and a secret, not either/or.
The response:
{
"token_type": "Bearer",
"expires_in": 3600,
"access_token": "…",
"id_token": "eyJ…",
"refresh_token": "…",
"scope": "openid profile email"
}Validate the ID token — never trust an unchecked JWT
A JWT is signed, not encrypted: anyone can read it, so you must prove it’s
genuine and meant for you. Fetch the IdP’s public keys from its jwks_uri
(listed in /.well-known/openid-configuration) and check, in order:
- Signature — verify against the JWKS key named by the token’s
kid. iss— matches your expected issuer exactly.aud— contains yourclient_id. (A token minted for another app is not for you — this is the “confused deputy” guard.)exp/iat/nbf— not expired, not from the future.nonce— equals the one you sent in step 2.
Only after all five do you believe a single claim inside.
Extracting user data — the access-token trap
Here’s the mistake I see most, and the reason this note exists. You have the tokens; you want the user’s email and name. The tempting move:
Decode the access token and read the claims out of it.
Don’t. In OIDC the correct sources of identity are:
- The ID token — the claims you already validated.
sub, and (if you requested the scopes)email,name,preferred_username, etc. - The
/userinfoendpoint — call it withAuthorization: Bearer <access_token>for fresh or fuller claims not packed into the ID token.
Why the access token is the wrong source, even when it’s a readable JWT:
- It’s opaque by contract. The OAuth spec says its format is the authorization server’s business. Both Ping and Auth0 can issue JWT access tokens — and can change or encrypt them tomorrow. Code that parses it is built on a promise nobody made you.
- Wrong audience. Its
audis your API, not your client. You’d be reading a document addressed to someone else. - Wrong purpose and lifetime. It’s a resource key, tuned for API calls, not a statement of identity for your login session.
The rule in one line
The access token is not proof of identity. It’s a key to an API. Identity comes from the ID token (or
/userinfo). Treat them as different documents for different readers — because that’s exactly what they are. This trap has its own note: an access token is not proof of identity.
On the identifier itself: key your user records on sub + iss, not
email. sub is the stable, opaque, per-issuer user ID. Email can change, and a
freed address can be reassigned to a different person — key on it and you’ve
built an account-takeover vector.
PingFederate vs. Auth0
The flow above is identical on both. What differs are endpoints and each provider’s quirks. Start from discovery — everything else is listed there:
https://<host-or-tenant>/.well-known/openid-configuration| PingFederate | Auth0 | |
|---|---|---|
| Authorize | /as/authorization.oauth2 | /authorize |
| Token | /as/token.oauth2 | /oauth/token |
| UserInfo | /idp/userinfo.openid | /userinfo |
| JWKS | /pf/JWKS | /.well-known/jwks.json |
PingFederate gotchas
- Whether the access token is a JWT or a reference (opaque) token is a config choice in the Access Token Manager — another reason not to hard-code an assumption about its shape.
- Which claims land in the ID token vs.
/userinfois governed by the token manager’s attribute contract and your OIDC policy. If a claim is “missing,” it’s usually not in the contract — check there before the code. - PKCE can be required per-client; enable it and stop worrying about the implicit flow entirely.
Auth0 gotchas
- You only get a JWT access token if you pass an
audience(your API identifier) in the auth request. Omit it and the access token is opaque — usable only against/userinfo. This surprises people who expect to decode it. - Custom claims must be namespaced — e.g.
https://app.example.com/roles, added via an Action. Auth0 silently drops non-namespaced custom claims, so a barerolesclaim just won’t appear. - The SPA/native SDKs use authorization-code + PKCE by default — lean on them rather than hand-rolling the redirect.
A mental checklist
-
openidin scope (noopenid, no ID token — you’re doing plain OAuth) -
statesent and re-verified on callback (CSRF) -
noncesent and checked inside the ID token (replay) - PKCE
S256, verifier kept client-side, sent at exchange - ID token validated: signature,
iss,aud,exp,nonce - Identity read from the ID token /
/userinfo, never the access token - Users keyed on
sub+iss, not email