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...

Y
Yash Pritwani
16 min read

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

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

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">&#x1f511;</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">&#x1f511;</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

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.

#oauth2#oidc#authentication#authelia#security

Need help with security?

TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.