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.
Defense in depth: multiple security layers protect your infrastructure from threats.
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();
}
Get more insights on Security
Join 2,000+ engineers who get our weekly deep-dives. No spam, unsubscribe anytime.
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'
Zero Trust architecture: every request is verified through identity, policy, and access proxy layers.
Token Management Best Practices
Access Token
- Short-lived (15 minutes to 1 hour)
- Used for API authentication
- Sent in Authorization header
- Do not store in localStorage (XSS vulnerable)
Refresh Token
- Long-lived (days to weeks)
- Used only to get new access tokens
- Store in HTTP-only secure cookie (web) or secure storage (mobile)
- Implement rotation (new refresh token on each use)
ID Token
- Contains user identity claims
- Validate on the client side (check iss, aud, exp)
- Do not send to APIs (use access token instead)
Token Storage Patterns
| Storage | Web | Mobile | Security |
|---|---|---|---|
| HTTP-only cookie | Best for web | N/A | Safe from XSS |
| localStorage | Not recommended | N/A | XSS vulnerable |
| sessionStorage | Acceptable | N/A | Tab-scoped, XSS risk |
| Secure Keychain | N/A | Best | OS-level encryption |
| In-memory | Good (short sessions) | Good | Lost on refresh |
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
Free Resource
Infrastructure Security Audit Template
The exact audit template we use with clients: 60+ checks across network, identity, secrets management, and compliance.
- Not validating the state parameter: Enables CSRF attacks
- Not validating ID token claims: Anyone could forge tokens
- Storing tokens in localStorage: XSS attacks can steal them
- Not implementing PKCE: Authorization code interception attacks
- Long-lived access tokens: Should be 15-60 minutes, not days
- Not rotating refresh tokens: Stolen refresh tokens have unlimited life
- Accepting tokens from wrong issuer: Validate the iss claim
- Not checking token expiration: Always verify exp claim
Encryption transforms readable plaintext into unreadable ciphertext, reversible only with the correct key.
Forward Auth vs OIDC: When to Use Each
| Approach | Forward Auth (Traefik + Authelia) | OIDC Integration |
|---|---|---|
| Implementation | Zero code (middleware) | Application code |
| User experience | Redirect to Authelia login | "Login with SSO" button |
| Fine-grained access | URL/path based | Scopes and claims |
| API support | Cookie-based (browsers only) | Bearer tokens (any client) |
| Best for | Protecting internal tools | Custom applications |
At TechSaaS, we use both:
- Forward auth for internal tools (Dozzle, Grafana, n8n): Zero code, just add Traefik middleware labels
- OIDC for applications that need user identity (Gitea, BookStack): Full SSO with user context
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.
Related Service
Security & Compliance
Zero-trust architecture, compliance automation, and incident response planning.
Need help with security?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.
We Will Build You a Demo Site — For Free
Like it? Pay us. Do not like it? Walk away, zero complaints. You will spend way less than hiring developers or any agency.
No spam. No contracts. Just a free demo.