caduh

OAuth 2.1 & OpenID Connect, Explained for Builders

7 min read

A practical guide to OAuth 2.1 and OpenID Connect for real apps: authorization code + PKCE, refresh tokens, ID tokens, and machine-to-machine flows without the usual auth confusion.

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. citeturn201483search2turn201483search20turn738018search2

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. citeturn738018search1turn738018search4turn738018search19

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. citeturn201483search1turn201483search7turn201483search2

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. citeturn738018search3turn738018search2turn201483search0

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. citeturn201483search0turn201483search2

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. citeturn738018search0turn738018search13

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. citeturn738018search1turn738018search19

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. citeturn201483search2turn201483search20

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

  1. Is this a user flow or a service flow?

    • user flow -> Authorization Code + PKCE
    • service flow -> Client Credentials
  2. Do we need to know who the user is?

    • yes -> add OIDC (scope=openid)
    • no -> plain OAuth may be enough
  3. What token is failing?

    • API auth issue -> inspect access token
    • login/user claim issue -> inspect ID token
    • expiry/session issue -> inspect refresh token policy
  4. 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.”