OAuth 2.1 & OpenID Connect, Explained for Builders
Auth code + PKCE, refresh tokens, and machine-to-machine flows
Goal: help you pick the right auth flow for web apps, SPAs, mobile apps, and backend services without mixing up authorization and authentication.
TL;DR
- OAuth is for authorization: letting a client access an API with limited scope.
- OpenID Connect (OIDC) is an identity layer on top of OAuth: it adds login, an ID token, and standard user claims.
- For user sign-in and delegated API access, use Authorization Code + PKCE.
- For long-lived sessions, prefer short-lived access tokens and carefully handled refresh tokens.
- For service-to-service calls, use Client Credentials. There is no user, so usually no ID token.
- Avoid legacy advice: Implicit and Resource Owner Password Credentials are out of favour and omitted from OAuth 2.1.
1) Start Here: OAuth vs OpenID Connect
OAuth 2.1 is the modernised direction of OAuth: it folds in security best practices such as requiring PKCE for the authorization code flow and omits insecure legacy grants like Implicit and ROPC. citeturn201483search2turn201483search20turn738018search2
OpenID Connect sits on top of OAuth and answers a different question. OAuth answers “what can this client access?” OIDC answers “who is the user who just signed in?” It does that by defining an ID token, standard scopes like openid, and standard claims plus the UserInfo endpoint. citeturn738018search1turn738018search4turn738018search19
The mental model
- Access token → for APIs
- ID token → for the client app to verify the user’s identity
- Refresh token → to get a new access token without making the user sign in again
If your app says “Login with X”, you almost certainly want OIDC, not plain OAuth.
2) The Default Flow: Authorization Code + PKCE
For browser apps, mobile apps, and server-rendered web apps, the safest default is Authorization Code + PKCE. PKCE was created to prevent authorization-code interception attacks, and OAuth 2.1 makes PKCE required for clients using the authorization code flow. citeturn201483search1turn201483search7turn201483search2
2.1 What PKCE actually does
The client creates:
- a random code verifier
- a derived code challenge
It sends the challenge during the browser redirect to the authorization server, then later proves possession by sending the original verifier at the token endpoint.
That way, a stolen authorization code is not enough by itself.
2.2 Flow, end to end
Browser/App -> Authorization Server: /authorize
response_type=code
client_id=...
redirect_uri=...
scope=openid profile email api.read
code_challenge=...
code_challenge_method=S256
state=...
nonce=... # OIDC
Authorization Server -> Browser/App: redirect back with ?code=...&state=...
Browser/App -> Authorization Server: /token
grant_type=authorization_code
code=...
redirect_uri=...
code_verifier=...
Authorization Server -> Client:
access_token
id_token # if OIDC scope included openid
refresh_token # optional, policy-dependent
expires_in
2.3 Use it for
- server-rendered web apps
- single-page apps
- native mobile apps
- BFF setups where the browser talks to your backend and the backend handles tokens
3) When You Need OpenID Connect
Use OIDC when the client needs to know who the user is.
Typical signs:
- you need sign-in
- you need the user’s identifier, email, or profile claims
- you want standards-based SSO across apps
- you need a standard logout/session story from your identity provider
3.1 The OIDC pieces that matter
scope=openid— turns an OAuth request into an OIDC request- ID token — signed token with identity data for the relying party
nonce— binds the sign-in response to the original auth request- UserInfo endpoint — fetches extra profile claims if needed
3.2 What not to do
- Don’t send the ID token to your APIs as if it were an access token.
- Don’t use the access token in the UI to decide who the user is when an ID token is available.
- Don’t assume every claim is always present; claims depend on scopes, consent, and provider policy.
4) Refresh Tokens Without Regret
Access tokens should usually be short-lived. Refresh tokens let the client obtain new access tokens without sending the user back through the login flow every hour. OAuth 2.1 defines the refresh token grant, and current security guidance recommends stronger protections around refresh-token use. citeturn738018search3turn738018search2turn201483search0
4.1 Good defaults
- Keep access tokens short-lived
- Issue refresh tokens selectively, not by default to every client type
- Store refresh tokens in the most protected place available
- Revoke refresh tokens on logout, credential change, or suspicious activity
4.2 Rotation matters
Current OAuth security guidance says refresh tokens for public clients should either be sender-constrained or use refresh token rotation. With rotation, every refresh returns a new refresh token and invalidates the previous one. That helps detect replay if a token is stolen and used twice. citeturn201483search0turn201483search2
4.3 Browser reality check
For SPAs, token storage is where many designs go wrong. The most robust pattern for many teams is a backend-for-frontend (BFF) that keeps refresh tokens server-side and gives the browser an HTTP-only session cookie instead of exposing long-lived tokens to JavaScript.
5) Machine-to-Machine: Client Credentials
When one backend service calls another and there is no end user in the flow, use Client Credentials. OAuth 2.1 includes this grant for the “application acting on its own behalf” case. citeturn738018search0turn738018search13
5.1 What it looks like
Service A -> Authorization Server: /token
grant_type=client_credentials
client_id=...
client_secret=... # or private_key_jwt / mTLS
scope=orders.read
Authorization Server -> Service A:
access_token
expires_in
token_type=bearer
5.2 Important boundaries
- There is no user session
- There is usually no refresh token
- There is usually no ID token
- Permissions come from service identity + scopes/audience, not from user consent
5.3 Use it for
- internal APIs
- workers and schedulers
- integration daemons
- service-to-service communication across trusted systems
6) Pick the Right Flow
| Scenario | Flow | Notes | |---|---|---| | User signs into web app | Authorization Code + PKCE + OIDC | Standard default | | SPA calling your APIs | Authorization Code + PKCE, often via BFF | Avoid long-lived tokens in browser JS | | Native mobile app | Authorization Code + PKCE + OIDC | Use system browser / app auth best practices | | Backend service calling API | Client Credentials | No user involved | | “User gives app access to their data” | Authorization Code + PKCE | Add OIDC if you also need sign-in |
7) Minimal Implementation Checklist
Authorization server / IdP
- Require exact redirect URI matching
- Require PKCE for authorization code flow
- Use short-lived access tokens
- Use refresh token rotation or sender-constrained refresh tokens where appropriate
- Validate audience, scope, and client type
- Support revocation and session logout flows
Client app
- Generate strong random state, nonce, and code verifier
- Validate returned state
- Verify ID token claims if using OIDC
- Never treat the browser as a secure vault
- Keep token scopes narrow
API / resource server
- Validate token signature and issuer
- Check audience and expiry
- Enforce scopes/roles per endpoint
- Log subject/client ID, not raw tokens
8) Common Builder Mistakes
Mistake 1: “OAuth is login”
Not quite. OAuth is delegated authorization. OIDC is the login layer. citeturn738018search1turn738018search19
Mistake 2: Using Implicit because “it’s for SPAs”
That advice is stale. OAuth 2.1 omits the Implicit grant and requires PKCE in the code flow instead. citeturn201483search2turn201483search20
Mistake 3: Storing long-lived tokens in localStorage
That turns XSS into account takeover. Prefer a BFF + HTTP-only cookies or otherwise minimise token lifetime and exposure.
Mistake 4: Sending ID tokens to APIs
APIs should expect access tokens, not ID tokens.
Mistake 5: Giving every token too much power
Use narrow scopes, clear audiences, short expiry, and revocation paths.
9) What “Good” Looks Like
- One default for user auth: Authorization Code + PKCE
- OIDC only where you truly need identity
- Short-lived access tokens, carefully handled refresh tokens
- Service accounts use Client Credentials, not fake user logins
- Redirect URIs, audiences, scopes, and token validation are all explicit
- The team can explain, in one sentence, the difference between access token, ID token, and refresh token
10) Copy-Paste Examples
10.1 Build the authorize URL
import crypto from "node:crypto";
function base64url(input: Buffer) {
return input.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
const codeVerifier = base64url(crypto.randomBytes(32));
const codeChallenge = base64url(
crypto.createHash("sha256").update(codeVerifier).digest()
);
const state = base64url(crypto.randomBytes(16));
const nonce = base64url(crypto.randomBytes(16));
const url = new URL("https://id.example.com/oauth2/authorize");
url.search = new URLSearchParams({
response_type: "code",
client_id: process.env.OIDC_CLIENT_ID!,
redirect_uri: "https://app.example.com/callback",
scope: "openid profile email api.read",
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
nonce,
}).toString();
10.2 Exchange the code for tokens
const tokenRes = await fetch("https://id.example.com/oauth2/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "https://app.example.com/callback",
client_id: process.env.OIDC_CLIENT_ID!,
code_verifier: codeVerifier,
}),
});
const tokens = await tokenRes.json();
10.3 Machine-to-machine token request
curl -X POST https://id.example.com/oauth2/token \
-H 'content-type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials&client_id=svc_orders&client_secret=REDACTED&scope=orders.read'
11) Sharp Edges
- Redirect URI mismatches: tiny differences break the flow by design.
- Clock skew: token validation fails if servers disagree on time.
- Multiple audiences: APIs reject tokens minted for the wrong resource.
- Mixed-up auth: using OAuth where you needed OIDC, or vice versa.
- Silent refresh nostalgia: old iframe-style patterns are fragile; use modern session/token strategies.
12) The 2 AM Runbook
-
Is this a user flow or a service flow?
- user flow -> Authorization Code + PKCE
- service flow -> Client Credentials
-
Do we need to know who the user is?
- yes -> add OIDC (
scope=openid) - no -> plain OAuth may be enough
- yes -> add OIDC (
-
What token is failing?
- API auth issue -> inspect access token
- login/user claim issue -> inspect ID token
- expiry/session issue -> inspect refresh token policy
-
What usually broke?
- bad redirect URI
- missing PKCE verifier
- state/nonce mismatch
- audience/scope mismatch
- expired or replayed refresh token
Appendix: Fast Rules of Thumb
- Login? Use OIDC.
- API access on behalf of user? Use OAuth code flow + PKCE.
- Backend talking to backend? Use Client Credentials.
- Public client? Treat it as untrusted and design accordingly.
- Refresh tokens? Rotate or sender-constrain them when required.
Ship the boring, standard flow. Auth is one place where “custom and clever” usually becomes “incident and postmortem.”