OAuth 2.0 is an authorization framework, not an authentication protocol — though teams constantly use it for login. Understanding that distinction prevents the most common implementation bugs: treating an access token as proof of identity, storing refresh tokens in localStorage, or skipping PKCE on public clients. This guide explains the actors, flows, tokens, and hardening steps you need when wiring "Sign in with Google" or building your own authorization server.
Authorization vs authentication
- Authentication answers: Who is this user?
- Authorization answers: What is this client allowed to do on the user's behalf?
OAuth 2.0 defines how a client obtains limited access to a resource server after the resource owner (usually the user) approves via an authorization server. OpenID Connect (OIDC) sits on top of OAuth and adds ID tokens for authentication — if you need profile info and a stable sub claim, use OIDC, not raw OAuth alone.
The four roles
| Role | Example |
|---|---|
| Resource owner | User with a Gmail account |
| Client | Your SaaS web app or mobile app |
| Authorization server | Google Accounts, Auth0, Keycloak |
| Resource server | Gmail API, your own API |
Tokens you will encounter
Access tokens are presented to APIs:
GET /v1/profile HTTP/1.1
Host: api.example.com
Authorization: Bearer ya29.a0AfH6SMBx...They are opaque strings or JWTs. Short lifetimes (5–60 minutes) limit exposure.
Refresh tokens are long-lived secrets used only against the token endpoint to obtain new access tokens. Store them server-side or in secure platform storage (iOS Keychain, Android Keystore) — never in browser localStorage.
Authorization codes are one-time, short-lived codes exchanged at the token endpoint. They are useless without the client secret (confidential clients) or PKCE verifier (public clients).
Decode JWT access tokens during development with /tools/jwt-decoder to verify iss, aud, exp, and custom scopes — but remember: never trust JWT contents without signature verification.
Scopes express least privilege
Scopes are space-delimited strings:
scope=read:projects write:projects offline_access
Request the minimum scope set. Users hesitate when an app asks for "full account access" to display a profile avatar. Document each scope in your developer portal and map scopes to backend permission checks — a scope string in a token means nothing if your API ignores it.
Authorization Code flow (the default)
Use this for server-side web apps and native apps with PKCE. It keeps secrets off the user agent.
Step 1 — Redirect user to authorize:
GET https://auth.example.com/oauth/authorize?
response_type=code&
client_id=app_123&
redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&
scope=read%3Aprojects&
state=random_csrf_token&
code_challenge=E9Melhoa2OwvFrEMTJguCHAOqPnybwJwgKluWzG1bE8&
code_challenge_method=S256 HTTP/1.1state prevents CSRF: generate random bytes, store in session, validate on callback.
Step 2 — User approves; browser returns:
GET /callback?code=SplxlOBeZQQYbYS6WxSbIA&state=random_csrf_token HTTP/1.1Step 3 — Backend exchanges code for tokens:
POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=SplxlOBeZQQYbYS6WxSbIA&
redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&
client_id=app_123&
client_secret=shhh&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkStep 4 — Token response:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "def50200a1b2c3...",
"scope": "read:projects"
}Implement this exchange on your backend only. The client secret must never ship in a SPA bundle.
PKCE for public clients
Single-page apps and mobile apps cannot hold a client secret. PKCE (Proof Key for Code Exchange) binds the authorization code to a verifier generated at request time:
// Browser — use Web Crypto API
const verifier = crypto.randomUUID() + crypto.randomUUID();
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(verifier)
);
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
sessionStorage.setItem('pkce_verifier', verifier);
// Send `challenge` in authorize URL; send `verifier` at token exchangeOAuth 2.1 consolidates best practices: public clients must use PKCE; implicit flow is deprecated.
Client Credentials flow
Machine-to-machine services use client ID + secret directly — no user involved:
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=reports:read&client_id=svc_456&client_secret=...Rotate service secrets through your secrets manager. Scope these tokens narrowly; they often bypass user-level audit trails.
Flows to avoid in new projects
Implicit flow (response_type=token) exposed access tokens in URL fragments. Do not build new integrations with it.
Resource Owner Password Credentials (username/password posted to token endpoint) bypasses the authorization server's consent UI. Only acceptable for migrating legacy first-party apps — and even then, prefer Authorization Code.
Device Authorization flow suits TVs and CLI tools: user visits a URL, enters a code, device polls until authorized. Useful pattern when no browser redirect is available.
Refresh token rotation
When an access token expires, exchange the refresh token:
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=def50200...&client_id=app_123Enable refresh token rotation: each use issues a new refresh token and invalidates the old one. Detect reuse (two requests with the same token) and revoke the entire token family — that pattern catches stolen refresh tokens.
Securing token endpoints
Hash client secrets at rest with a slow algorithm (Argon2, bcrypt). Compare using constant-time functions via /tools/hash-generator test vectors during implementation reviews.
Rate-limit /oauth/token and /oauth/authorize to slow brute force and credential stuffing.
Log authorization events (client_id, user_id, scopes, IP) without logging raw tokens or secrets.
Bind tokens to sender when feasible (cnf claim in JWTs for mTLS or DPoP).
Validating tokens in your API
For JWT access tokens:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
export async function verifyAccessToken(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
if (!payload.scope?.includes('read:projects')) {
throw new Error('insufficient_scope');
}
return payload;
}Check exp with clock skew tolerance (±60 seconds). Reject none algorithm tokens. For opaque tokens, call the authorization server's introspection endpoint:
POST /oauth/introspect HTTP/1.1
Content-Type: application/x-www-form-urlencoded
token=ya29...&client_id=app_123&client_secret=shhhOAuth implementation checklist
- Authorization Code + PKCE for browser and mobile clients
-
stateparameter validated on every callback - Redirect URI allowlist enforced (exact match, no wildcards in production)
- Refresh tokens stored securely and rotated
- Scopes enforced at the resource server, not just printed in docs
- Token lifetimes documented; revocation endpoint available
- Client secrets hashed; no secrets in frontend bundles
- JWT signatures verified against issuer JWKS (if using JWT access tokens)
Revocation and logout
OAuth defines token revocation:
POST /oauth/revoke HTTP/1.1
Content-Type: application/x-www-form-urlencoded
token=def50200...&token_type_hint=refresh_token&client_id=app_123&client_secret=shhhCall revocation when users change passwords, disable accounts, or click "Sign out everywhere." Access tokens may remain valid until expiry unless you maintain a denylist — short access token TTL (15 minutes or less) shrinks that window.
For federated logout, OIDC adds end_session_endpoint to clear the authorization server session; without it, users re-authenticate silently on next login because the IdP cookie persists.
Registering clients safely
Dynamic client registration (RFC 7591) lets apps self-register — useful for marketplaces, dangerous without approval workflows. For first-party apps, register clients manually:
| Setting | Recommendation |
|---|---|
redirect_uris | Exact HTTPS URLs only; no http://localhost in production |
grant_types | authorization_code only for user-facing apps |
response_types | code only — disable token |
token_endpoint_auth_method | client_secret_post or private_key_jwt for confidential clients |
Store client_id in frontend config (public) but never client_secret. Rotate secrets by issuing a second secret, migrating backends, then revoking the old one.
Common integration failures
Redirect URI mismatch — https://app.com/callback vs https://app.com/callback/ fails. Normalize trailing slashes in docs and tests.
Clock skew — JWT exp validation rejects tokens when server clocks drift. Sync NTP on all nodes.
Scope downgrade ignored — client requests read write but token returns read only; verify the granted scope string, not what you asked for.
Mixing authentication protocols — using access tokens where ID tokens belong conflates API access with login proof. OIDC ID tokens go to the session layer; access tokens go to API calls.
Debugging tips
When integrations fail, compare the authorize URL parameters character by character — encoding mistakes in redirect_uri cause silent failures.
Use /tools/jwt-generator to craft test tokens with near-expiry exp claims and verify your API rejects them.
If users report "logged in as wrong account," check whether you keyed sessions on email instead of the stable sub claim from OIDC.
Conclusion
OAuth 2.0 delegates access without sharing passwords. Implement Authorization Code with PKCE, treat scopes as enforceable permissions, and keep refresh tokens out of untrusted storage. Layer OpenID Connect when you need authenticated identity, not just API access.
Next steps: Read the JWT complete guide for ID token structure, inspect real tokens with /tools/jwt-decoder, and add automated tests that walk through the full authorize → callback → token exchange path in CI.
Try these free tools
Put what you learned into practice — no signup required.