OAuth 2.0 and OIDC Implementation Guide: From Theory to Production
Implement OAuth 2.0 and OpenID Connect correctly. Authorization code flow with PKCE, token management, refresh token rotation, and integration with...
OAuth 2.0 and OIDC: The Foundation of Modern Auth
OAuth 2.0 is the authorization framework. OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0. Together, they power "Login with Google/GitHub/Microsoft" and single sign-on (SSO) for enterprise applications.
<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 220" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="220" rx="12" fill="#1a1a2e"/><path d="M300,25 L380,55 L380,120 Q380,170 300,195 Q220,170 220,120 L220,55 Z" fill="none" stroke="#6366f1" stroke-width="2.5"/><path d="M300,40 L365,65 L365,118 Q365,160 300,180 Q235,160 235,118 L235,65 Z" fill="#6366f1" opacity="0.15"/><rect x="280" y="95" width="40" height="30" rx="4" fill="#6366f1" opacity="0.9"/><path d="M288,95 L288,82 Q288,72 300,72 Q312,72 312,82 L312,95" fill="none" stroke="#6366f1" stroke-width="2.5"/><circle cx="300" cy="110" r="4" fill="#ffffff"/><text x="90" y="60" text-anchor="middle" fill="#3b82f6" font-size="10" font-family="system-ui">Firewall</text><line x1="130" y1="57" x2="218" y2="57" stroke="#3b82f6" stroke-width="1" stroke-dasharray="3,3"/><text x="90" y="100" text-anchor="middle" fill="#a855f7" font-size="10" font-family="system-ui">WAF</text><line x1="110" y1="97" x2="220" y2="85" stroke="#a855f7" stroke-width="1" stroke-dasharray="3,3"/><text x="90" y="140" text-anchor="middle" fill="#2dd4bf" font-size="10" font-family="system-ui">SSO / MFA</text><line x1="130" y1="137" x2="222" y2="120" stroke="#2dd4bf" stroke-width="1" stroke-dasharray="3,3"/><text x="510" y="60" text-anchor="middle" fill="#f59e0b" font-size="10" font-family="system-ui">TLS/SSL</text><line x1="470" y1="57" x2="382" y2="57" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3,3"/><text x="510" y="100" text-anchor="middle" fill="#3b82f6" font-size="10" font-family="system-ui">RBAC</text><line x1="490" y1="97" x2="380" y2="85" stroke="#3b82f6" stroke-width="1" stroke-dasharray="3,3"/><text x="510" y="140" text-anchor="middle" fill="#a855f7" font-size="10" font-family="system-ui">Audit Logs</text><line x1="470" y1="137" x2="378" y2="120" stroke="#a855f7" stroke-width="1" stroke-dasharray="3,3"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Defense in depth: multiple security layers protect your infrastructure from threats.</p></div>
The Flows
Authorization Code Flow with PKCE (Recommended)
This is the recommended flow for all modern applications (web, mobile, SPA):
1. User clicks "Login"
2. App generates code_verifier (random string) and code_challenge (SHA256 hash)
3. App redirects to Authorization Server:
GET /authorize?
response_type=code&
client_id=my-app&
redirect_uri=https://app.example.com/callback&
scope=openid profile email&
state=random-csrf-token&
code_challenge=SHA256_HASH&
code_challenge_method=S256
4. User authenticates with the Authorization Server
5. Authorization Server redirects back:
GET /callback?code=AUTH_CODE&state=random-csrf-token
6. App exchanges code for tokens (server-side):
POST /token
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://app.example.com/callback&
client_id=my-app&
client_secret=SECRET&
code_verifier=ORIGINAL_RANDOM_STRING
7. Authorization Server returns:
{
"access_token": "eyJhbG...",
"id_token": "eyJhbG...",
"refresh_token": "dGhpcy...",
"token_type": "Bearer",
"expires_in": 3600
}Implementation in Node.js
import { randomBytes, createHash } from 'crypto';
import express from 'express';
const app = express();
const OIDC_CONFIG = {
issuer: 'https://auth.techsaas.cloud',
authorizationEndpoint: 'https://auth.techsaas.cloud/api/oidc/authorization',
tokenEndpoint: 'https://auth.techsaas.cloud/api/oidc/token',
userinfoEndpoint: 'https://auth.techsaas.cloud/api/oidc/userinfo',
clientId: 'my-app',
clientSecret: process.env.OIDC_CLIENT_SECRET,
redirectUri: 'https://app.example.com/auth/callback',
scopes: ['openid', 'profile', 'email'],
};
// Generate PKCE challenge
function generatePKCE() {
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
// Step 1: Redirect to login
app.get('/auth/login', (req, res) => {
const state = randomBytes(16).toString('hex');
const { verifier, challenge } = generatePKCE();
// Store in session (server-side)
req.session.oauthState = state;
req.session.codeVerifier = verifier;
const params = new URLSearchParams({
response_type: 'code',
client_id: OIDC_CONFIG.clientId,
redirect_uri: OIDC_CONFIG.redirectUri,
scope: OIDC_CONFIG.scopes.join(' '),
state: state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
res.redirect(OIDC_CONFIG.authorizationEndpoint + '?' + params.toString());
});
// Step 2: Handle callback
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state (CSRF protection)
if (state !== req.session.oauthState) {
return res.status(403).json({ error: 'Invalid state' });
}
// Exchange code for tokens
const tokenResponse = await fetch(OIDC_CONFIG.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code as string,
redirect_uri: OIDC_CONFIG.redirectUri,
client_id: OIDC_CONFIG.clientId,
client_secret: OIDC_CONFIG.clientSecret,
code_verifier: req.session.codeVerifier,
}),
});
const tokens = await tokenResponse.json();
// Validate ID token
const claims = decodeJWT(tokens.id_token);
if (claims.iss !== OIDC_CONFIG.issuer) throw new Error('Invalid issuer');
if (claims.aud !== OIDC_CONFIG.clientId) throw new Error('Invalid audience');
if (claims.exp < Date.now() / 1000) throw new Error('Token expired');
// Store tokens in session
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
req.session.user = {
sub: claims.sub,
name: claims.name,
email: claims.email,
};
// Clean up
delete req.session.oauthState;
delete req.session.codeVerifier;
res.redirect('/dashboard');
});
// Step 3: Refresh tokens
async function refreshAccessToken(refreshToken) {
const response = await fetch(OIDC_CONFIG.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: OIDC_CONFIG.clientId,
client_secret: OIDC_CONFIG.clientSecret,
}),
});
return response.json();
}Setting Up an OIDC Provider
Authelia (Self-Hosted)
At TechSaaS, we use Authelia as our OIDC provider for all services:
# authelia/config.yml (OIDC section)
identity_providers:
oidc:
hmac_secret: 'a-long-random-secret'
jwks:
- key_id: 'main'
algorithm: 'RS256'
use: 'sig'
key: |
-----BEGIN RSA PRIVATE KEY-----
(generated with: openssl genrsa -out oidc-jwks.pem 4096)
-----END RSA PRIVATE KEY-----
clients:
- client_id: 'gitea'
client_name: 'Gitea'
client_secret: 'hashed_secret_here'
public: false
authorization_policy: 'two_factor'
redirect_uris:
- 'https://git.techsaas.cloud/user/oauth2/Authelia/callback'
scopes:
- 'openid'
- 'profile'
- 'email'
- 'groups'
token_endpoint_auth_method: 'client_secret_basic'
- client_id: 'bookstack'
client_name: 'BookStack'
client_secret: 'another_hashed_secret'
public: false
authorization_policy: 'one_factor'
redirect_uris:
- 'https://docs.techsaas.cloud/oidc/callback'
scopes:
- 'openid'
- 'profile'
- 'email'
- client_id: 'my-custom-app'
client_name: 'My App'
client_secret: 'app_hashed_secret'
public: false
authorization_policy: 'one_factor'
redirect_uris:
- 'https://app.techsaas.cloud/auth/callback'
- 'http://localhost:3000/auth/callback'
scopes:
- 'openid'
- 'profile'
- 'email'
- 'groups'
grant_types:
- 'authorization_code'
- 'refresh_token'
response_types:
- 'code'<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 180" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="180" rx="12" fill="#1a1a2e"/><circle cx="60" cy="90" r="20" fill="none" stroke="#3b82f6" stroke-width="2"/><text x="60" y="94" text-anchor="middle" fill="#3b82f6" font-size="11" font-family="system-ui">User</text><rect x="120" y="65" width="95" height="50" rx="8" fill="#6366f1" opacity="0.85"/><text x="167" y="85" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Identity</text><text x="167" y="100" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Verify</text><rect x="250" y="65" width="95" height="50" rx="8" fill="#a855f7" opacity="0.85"/><text x="297" y="85" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Policy</text><text x="297" y="100" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Engine</text><rect x="380" y="65" width="95" height="50" rx="8" fill="#2dd4bf" opacity="0.85"/><text x="427" y="85" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Access</text><text x="427" y="100" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Proxy</text><rect x="510" y="65" width="60" height="50" rx="8" fill="#f59e0b" opacity="0.85"/><text x="540" y="94" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">App</text><defs><marker id="arrow5" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#e2e8f0"/></marker></defs><line x1="82" y1="90" x2="118" y2="90" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow5)"/><line x1="217" y1="90" x2="248" y2="90" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow5)"/><line x1="347" y1="90" x2="378" y2="90" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow5)"/><line x1="477" y1="90" x2="508" y2="90" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow5)"/><text x="167" y="140" text-anchor="middle" fill="#94a3b8" font-size="9" font-family="system-ui">MFA + Device</text><text x="297" y="140" text-anchor="middle" fill="#94a3b8" font-size="9" font-family="system-ui">Least Privilege</text><text x="427" y="140" text-anchor="middle" fill="#94a3b8" font-size="9" font-family="system-ui">Encrypted Tunnel</text><text x="300" y="165" text-anchor="middle" fill="#6366f1" font-size="11" font-family="system-ui" font-weight="bold">Never Trust, Always Verify</text></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Zero Trust architecture: every request is verified through identity, policy, and access proxy layers.</p></div>
Token Management Best Practices
Access Token
Refresh Token
ID Token
Token Storage Patterns
|---------|-----|--------|----------|
Recommended web pattern:
// Backend for Frontend (BFF) pattern
// Access token stored in-memory on the server
// Session cookie (HTTP-only, secure, SameSite) links to server session
// Refresh token stored server-side, never sent to browser
app.get('/api/data', async (req, res) => {
// Session cookie identifies the user
const session = getSession(req.cookies.session_id);
if (!session) return res.status(401).json({ error: 'Not authenticated' });
// Access token stored server-side
let accessToken = session.accessToken;
// Refresh if expired
if (isExpired(accessToken)) {
const tokens = await refreshAccessToken(session.refreshToken);
session.accessToken = tokens.access_token;
session.refreshToken = tokens.refresh_token; // Rotation
accessToken = tokens.access_token;
}
// Proxy API call with access token
const data = await fetch('https://api.example.com/data', {
headers: { Authorization: 'Bearer ' + accessToken },
});
res.json(await data.json());
});Common OIDC Security Mistakes
1. Not validating the state parameter: Enables CSRF attacks 2. Not validating ID token claims: Anyone could forge tokens 3. Storing tokens in localStorage: XSS attacks can steal them 4. Not implementing PKCE: Authorization code interception attacks 5. Long-lived access tokens: Should be 15-60 minutes, not days 6. Not rotating refresh tokens: Stolen refresh tokens have unlimited life 7. Accepting tokens from wrong issuer: Validate the iss claim 8. Not checking token expiration: Always verify exp claim
<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 150" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="150" rx="12" fill="#1a1a2e"/><rect x="30" y="40" width="100" height="55" rx="6" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="80" y="60" text-anchor="middle" fill="#3b82f6" font-size="10" font-family="monospace">Hello World</text><text x="80" y="80" text-anchor="middle" fill="#94a3b8" font-size="9" font-family="system-ui">Plaintext</text><rect x="175" y="30" width="90" height="75" rx="8" fill="#6366f1" opacity="0.85"/><text x="220" y="55" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Encrypt</text><text x="220" y="72" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">AES-256</text><text x="220" y="92" text-anchor="middle" fill="#f59e0b" font-size="20" font-family="system-ui">🔑</text><rect x="310" y="40" width="100" height="55" rx="6" fill="none" stroke="#a855f7" stroke-width="1.5"/><text x="360" y="60" text-anchor="middle" fill="#a855f7" font-size="10" font-family="monospace">x8f2...k9z</text><text x="360" y="80" text-anchor="middle" fill="#94a3b8" font-size="9" font-family="system-ui">Ciphertext</text><rect x="455" y="30" width="90" height="75" rx="8" fill="#2dd4bf" opacity="0.85"/><text x="500" y="55" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Decrypt</text><text x="500" y="72" text-anchor="middle" fill="#1a1a2e" font-size="9" font-family="system-ui">AES-256</text><text x="500" y="92" text-anchor="middle" fill="#f59e0b" font-size="20" font-family="system-ui">🔑</text><defs><marker id="arrow6" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#e2e8f0"/></marker></defs><line x1="132" y1="67" x2="173" y2="67" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow6)"/><line x1="267" y1="67" x2="308" y2="67" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow6)"/><line x1="412" y1="67" x2="453" y2="67" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow6)"/><text x="300" y="130" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Symmetric Encryption: same key encrypts and decrypts</text></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Encryption transforms readable plaintext into unreadable ciphertext, reversible only with the correct key.</p></div>
Forward Auth vs OIDC: When to Use Each
|----------|----------------------------------|------------------|
At TechSaaS, we use both:
This dual approach gives us SSO across all 33 services without modifying most of them. Only applications that need to know who the user is (for authorization, personalization, or audit) use OIDC directly.
Need help with security?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.