How OAuth 2.0 and OIDC actually work — authorization code flow, PKCE, access tokens, refresh tokens, and when to use each grant type.
OAuth 2.0 powers "Sign in with Google", "Login with GitHub", and most API authorization. Understanding it prevents critical security mistakes. This guide explains the flows that matter without the RFC jargon.
These are often confused:
OAuth 2.0 gives you an access token (to call APIs). OIDC additionally gives you an ID token (a JWT with user identity claims).
Resource Owner — the user (you)
Client — the app requesting access (your web/mobile app)
Authorization Server — issues tokens (Google, GitHub, Auth0, your own server)
Resource Server — the API being protected (Google Drive API, GitHub API)
Used for server-side apps and SPAs with PKCE. This is the most secure flow:
1. User clicks 'Login with Google'
2. Client redirects to Authorization Server:
GET https://accounts.google.com/o/oauth2/auth?
response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=openid email profile
&state=RANDOM_CSRF_TOKEN
&code_challenge=CODE_CHALLENGE (PKCE)
&code_challenge_method=S256 (PKCE)
3. User logs in and approves
4. Authorization Server redirects back:
GET https://yourapp.com/callback?code=AUTH_CODE&state=RANDOM_CSRF_TOKEN
5. Client exchanges code for tokens (server-to-server):
POST https://oauth2.googleapis.com/token
{ code, client_id, client_secret, redirect_uri, code_verifier }
6. Authorization Server returns:
{ access_token, refresh_token, id_token, expires_in }
7. Client uses access_token to call the API
Authorization: Bearer ACCESS_TOKEN
PKCE (pronounced 'pixie') prevents authorization code interception attacks. Required for public clients (SPAs, mobile apps) since they can't store a client secret:
// 1. Generate a random code_verifier
const codeVerifier = crypto.randomBytes(32).toString('base64url');
// 2. Hash it to get code_challenge
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// 3. Send code_challenge in the auth request
// 4. Send code_verifier when exchanging the code
// The server verifies: SHA256(code_verifier) === code_challenge
| Token | Format | Lifespan | Purpose |
|---|---|---|---|
| Access Token | JWT or opaque | 15min – 1hr | Call protected APIs |
| Refresh Token | Opaque | Days – months | Get new access tokens |
| ID Token | JWT (OIDC only) | Short | Verify user identity |
Scopes limit what the access token can do:
openid — basic OIDC (required for ID token)
profile — name, picture, locale
email — email address
read:repos — GitHub: read repositories
write:repos — GitHub: write repositories
https://www.googleapis.com/auth/drive.readonly
Request only the scopes you need — users are more likely to approve minimal permissions.
For machine-to-machine communication (no user involved):
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&scope=read:data
Use this for backend services calling each other, CI/CD pipelines, scheduled jobs.
When your API receives an access token JWT, verify it properly:
const { createRemoteJWKSet, jwtVerify } = require('jose');
const JWKS = createRemoteJWKSet(
new URL('https://accounts.google.com/.well-known/jwks.json')
);
async function validateToken(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://accounts.google.com',
audience: 'YOUR_CLIENT_ID',
});
// payload is verified and typed
return payload;
}
Never skip signature verification. Never trust the claims in an unverified JWT. Use the JWT Decoder and JWT Claims Validator tools to inspect tokens during development.