OAuth 2.0 is an authorization framework — it lets applications access third-party resources (Google, GitHub) on behalf of a user. OpenID Connect (OIDC) adds an authentication layer on top of it. Together they're the standard for modern web auth. This article clarifies the concepts and walks through the Authorization Code Flow step by step.
Roles
- Resource Owner: the end user
- Client: your application — acting on behalf of the user
- Authorization Server: the identity provider (Google, Auth0, Keycloak)
- Resource Server: the API (GitHub API, Google Drive API)
Flow Types
- Authorization Code + PKCE: the modern standard for web and mobile
- Client Credentials: server-to-server (no UI)
- Device Code: for devices without a browser such as TVs and CLIs
- Implicit: deprecated, never use it
- Password: deprecated, never use it
Authorization Code Flow (with PKCE)
┌─────────┐ 1. User clicks the login button
│ Client │ ────────────────────────────┐
│ (Web) │ ↓
└─────────┘ 2. Browser redirect →
↑ /authorize?client_id=X&redirect_uri=...
│ &code_challenge=HASH&state=RANDOM
│ │
│ ┌─────────────────────┴──┐
│ │ Authorization Server │
│ │ (the user logs in) │
│ └─────────────────────┬──┘
│ │
│ 3. Redirect back with a code │
│ ←──────────────────────────────────┘
│ /callback?code=XXX&state=RANDOM
│
│ 4. Token exchange (server-side)
└──────→ POST /token
code=XXX&code_verifier=ORIGINAL
→ { access_token, refresh_token, id_token }
Why Is PKCE Required?
Public clients (browsers, mobile apps) cannot safely store a client_secret. PKCE (Proof Key for Code Exchange): on every login, generate a random code_verifier, send its SHA-256 hash as code_challenge to /authorize, then send the original code_verifier at token exchange so the auth server can verify it.
// 1) Generate the PKCE challenge
import { randomBytes, createHash } from 'crypto';
const codeVerifier = randomBytes(32).toString('base64url');
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
// Stash in the session
session.codeVerifier = codeVerifier;
session.state = randomBytes(16).toString('hex');
// 2) Build the redirect URL
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: session.state
});
res.redirect(`https://auth.example.com/authorize?${params}`);
// 3) /callback endpoint
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
if (state !== session.state) return res.status(400).send('Invalid state');
// Token exchange
const tokenRes = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: session.codeVerifier
})
});
const { access_token, id_token, refresh_token } = await tokenRes.json();
// id_token → OIDC user info
// access_token → call APIs
// refresh_token → refresh the access_token
});
OIDC: the id_token
OAuth only grants authorization, not identity. OIDC carries the user's identity in an id_token (a JWT). Inside it: sub (user id), email, name, iss (issuer), aud (audience), exp (expiry).
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
const { payload } = await jwtVerify(id_token, JWKS, {
issuer: 'https://auth.example.com',
audience: CLIENT_ID
});
// payload.sub, payload.email, payload.name
// Upsert the user in your DB
let user = await db.users.findOne({ oauthSub: payload.sub });
if (!user) {
user = await db.users.create({
oauthSub: payload.sub,
email: payload.email,
name: payload.name
});
}
// Issue a session or JWT (your own token)
Scopes
Scopes determine which resources an access_token can reach. Ask for only the minimum needed — least privilege.
# Core OIDC
openid # required — for id_token
profile # name, picture
email # email, email_verified
# Provider-specific
read:user # GitHub
https://www.googleapis.com/auth/drive.readonly # Google Drive
Refresh Tokens
async function refreshAccessToken(refreshToken) {
const res = await fetch('https://auth.example.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID
})
});
if (!res.ok) throw new Error('Refresh failed');
return await res.json(); // new { access_token, refresh_token (rotated) }
}
// When the access token expires, refresh automatically
if (tokenExpiresAt < Date.now()) {
const newTokens = await refreshAccessToken(session.refreshToken);
session.accessToken = newTokens.access_token;
session.refreshToken = newTokens.refresh_token; // rotated
session.tokenExpiresAt = Date.now() + newTokens.expires_in * 1000;
}
Security Checklist
- state parameter is mandatory for CSRF
- PKCE is mandatory for public clients
- HTTPS on all endpoints
- Always validate the id_token signature and iss/aud
- Store the refresh token in an HttpOnly cookie or on a secure backend
- Logout: front-channel or back-channel — revoke the sessionId
Quick Social Login Setup
Instead of writing the whole flow yourself, use ready-made libraries: Passport.js (Node), NextAuth.js / Auth.js, Lucia Auth, Clerk, Supabase Auth. Ten lines of config gets you Google/GitHub/Apple login.
Conclusion
OAuth/OIDC looks complex at first, but once the logic clicks it reveals itself as an engineering masterpiece. In 2025 there is no good reason for any public app to build its own password system; Google/Apple/GitHub login gives your users convenience and you safety.
Reach out to KEYDAL for OAuth, SSO and identity providers such as Keycloak. Contact us