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 /userinfo endpoint. You opt into it by putting openid in your scope.

Three parties, three tokens:

PartyRole
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
TokenWhat it’s forWho reads it
ID token (JWT)Identity — proves who logged inYour client
Access tokenAuthorization — a key to an APIYour resource server
Refresh tokenGetting new tokens without re-loginYour 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:

  1. Before redirecting, the client invents a random secret — the code_verifier.
  2. It sends only a hash of it — the code_challenge — in the auth request.
  3. 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=S256

Every parameter earns its place:

  • response_type=code — ask for a code, not a token. This is the authorization-code flow.
  • scope=openid … — openid is what turns OAuth into OIDC and gets you an ID token. profile email request 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 use S256, never plain.

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_VALUE

Verify 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 (via client_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:

  1. Signature — verify against the JWKS key named by the token’s kid.
  2. iss — matches your expected issuer exactly.
  3. aud — contains your client_id. (A token minted for another app is not for you — this is the “confused deputy” guard.)
  4. exp / iat / nbf — not expired, not from the future.
  5. 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 /userinfo endpoint — call it with Authorization: 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 aud is 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
PingFederateAuth0
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. /userinfo is 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 bare roles claim 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

  • openid in scope (no openid, no ID token — you’re doing plain OAuth)
  • state sent and re-verified on callback (CSRF)
  • nonce sent 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