jan-karel.com
Home / Security Measures / Web Security / OAuth & OpenID Connect

OAuth & OpenID Connect

OAuth & OpenID Connect

OAuth & OpenID Connect

Login Without Loose Ends

Secure web development is not about adding friction, but about better defaults in design, code and release flow.

In OAuth & OpenID Connect, robust identity is what matters most: strong authentication, reliable session management and minimal privilege.

That makes security less of a separate afterthought and more a standard quality of your product.

Immediate measures (15 minutes)

Why This Matters

The core of OAuth & OpenID Connect is risk reduction in practice. Technical context supports the choice of measures, but implementation and enforcement are central.

OAuth 2.0 in Brief

OAuth 2.0 (RFC 6749) is an authorization framework, not an authentication protocol. It allows an application to access an API on behalf of a user, without the user giving their password to that application.

Roles

Role Description Example
Resource Owner The user who grants access You, the end user
Client The application that wants access Your web application or mobile app
Authorization Server (AS) Issues tokens after consent Google Accounts, Azure AD, Keycloak
Resource Server (RS) The API that provides protected data Google Calendar API, Microsoft Graph

Authorization Code flow

This is the recommended flow for virtually all scenarios. The diagram:

 Resource Owner        Client (webapp)       Auth Server        Resource Server
      |                      |                    |                    |
      | 1. Click "Login"      |                    |                    |
      |--------------------->|                    |                    |
      |                      | 2. Redirect + client_id, scope,        |
      |                      |    redirect_uri, state, code_challenge  |
      |<---------------------|------------------>|                    |
      | 3. Login + consent   |                    |                    |
      |-------------------------------------->--->|                    |
      | 4. Redirect + authorization code          |                    |
      |<------------------------------------------|                    |
      |--------------------->|                    |                    |
      |                      | 5. Code + code_verifier + secret       |
      |                      |------------------>|                    |
      |                      | 6. Access + refresh + id_token         |
      |                      |<------------------|                    |
      |                      | 7. API request + access token          |
      |                      |-------------------------------------->|
      |                      | 8. Protected resource                  |
      |                      |<--------------------------------------|
      | 9. Data to user      |                    |                    |
      |<---------------------|                    |                    |

The key: the authorization code travels via the browser (front-channel), but the access token is retrieved server-to-server (back-channel).

Why Implicit flow is deprecated

The Implicit flow (response_type=token) delivers the access token directly in the URL fragment. Problems: token visible in browser history/logs, no refresh tokens, leakage via Referer headers, no client authentication, not combinable with PKCE. RFC 9700 is clear: do not use Implicit flow anymore. Use Authorization Code + PKCE for all clients, including SPAs.

PKCE (Proof Key for Code Exchange)

PKCE (RFC 7636, pronounced "pixy") protects the authorization code against interception. Originally designed for mobile/native apps (public clients), but now required for all clients – including confidential clients with a client_secret.

Why required for all clients

Without PKCE, an attacker who intercepts the authorization code can exchange it for an access token. PKCE makes the code worthless without the associated code_verifier, which only the legitimate client knows.

How it works

  1. Client generates a random code_verifier (43-128 characters, URL-safe)
  2. Client computes code_challenge = BASE64URL(SHA256(code_verifier))
  3. Client sends code_challenge along in the authorization request
  4. Authorization Server stores the challenge alongside the code
  5. Client sends code_verifier when exchanging the code
  6. AS verifies: BASE64URL(SHA256(code_verifier)) == stored challenge

Code: Python/Flask PKCE implementation

import hashlib, base64, secrets
from flask import session, redirect, request, url_for, abort
import requests

CLIENT_ID     = "your-client-id"
CLIENT_SECRET = "your-client-secret"
AUTH_URL      = "https://idp.example.com/authorize"
TOKEN_URL     = "https://idp.example.com/token"
REDIRECT_URI  = "https://app.example.com/callback"

def _generate_pkce():
    verifier = secrets.token_urlsafe(64)                             # 86 chars
    digest   = hashlib.sha256(verifier.encode("ascii")).digest()
    challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
    return verifier, challenge

@app.route("/login")
def login():
    verifier, challenge = _generate_pkce()
    state = secrets.token_urlsafe(32)
    session["oauth_code_verifier"] = verifier   # server-side session!
    session["oauth_state"] = state

    return redirect(f"{AUTH_URL}?" + requests.compat.urlencode({
        "response_type": "code", "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI, "scope": "openid profile email",
        "state": state, "code_challenge": challenge,
        "code_challenge_method": "S256",
    }))

@app.route("/callback")
def callback():
    if request.args.get("state") != session.pop("oauth_state", None):
        abort(403, "State mismatch -- possible CSRF")
    if "error" in request.args:
        abort(400, f"OAuth error: {request.args['error']}")

    resp = requests.post(TOKEN_URL, data={
        "grant_type": "authorization_code",
        "code": request.args["code"],
        "redirect_uri": REDIRECT_URI,
        "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,
        "code_verifier": session.pop("oauth_code_verifier"),
    }, timeout=10)
    resp.raise_for_status()
    tokens = resp.json()

    session["access_token"]  = tokens["access_token"]
    session["refresh_token"] = tokens.get("refresh_token")
    # Validate id_token (see section 16.7)
    return redirect(url_for("dashboard"))

Always use S256 as code_challenge_method. The plain method offers no protection and is rejected by some Authorization Servers.

Redirect URI validation

The redirect URI is the most abused component of OAuth. An open redirect in the redirect URI allows an attacker to steal the authorization code.

Rules

  1. Exact match – redirect URI must exactly match the registered URI
  2. No wildcardshttps://*.example.com/callback is not safe
  3. No open redirectshttps://app.example.com/redirect?url=evil.com is fatal
  4. HTTPS required – except for localhost during development

Common mistakes

Pattern Safe? Reason
https://app.example.com/callback Yes Exact match, specific path
https://app.example.com/callback?extra=1 No Query parameters can be manipulated
https://*.example.com/callback No Subdomain wildcard: attacker can register evil.example.com
https://app.example.com/ No Too broad, any path under / matches
https://app.example.com/redirect?url=... No Open redirect, code leaks to external URL
http://app.example.com/callback No No TLS, code can be intercepted
https://app.example.com/callback/../admin No Path traversal, URI normalization can mislead
localhost:8080/callback Dev only Acceptable during development, never in production

Server-side validation

ALLOWED_REDIRECT_URIS = {
    "https://app.example.com/callback",
    "https://app.example.com/auth/callback",
}


def validate_redirect_uri(uri: str) -> bool:
    """Validate redirect URI with exact string matching."""
    # No normalization, no parsing -- exact match
    return uri in ALLOWED_REDIRECT_URIS

Do not use regex or substring matching for redirect URI validation. startswith("https://app.example.com") also matches https://app.example.com.evil.com.

Token security

Access tokens

Property Recommendation
Lifetime 5-15 minutes
Format Opaque string or signed JWT
Content No sensitive data (PII, passwords)
Transport Only via Authorization: Bearer header, never in URL
Client-side storage No – store in server-side session
Revocation Token introspection endpoint or short lifetime as alternative

Short lifetime is the most important measure. A stolen token valid for 5 minutes drastically limits the damage.

Refresh tokens

Property Recommendation
Lifetime Hours to days (depending on risk profile)
Rotation Issue a new refresh token on each use, invalidate the old one
Storage Server-side, encrypted, never in the browser
Binding Bind to client_id, user, and preferably to device/IP
Revocation Implement revocation endpoint (RFC 7009)

Refresh token rotation detects theft: if a stolen refresh token is used after the legitimate client has already obtained a new token, both tokens become invalid.

ID tokens (OpenID Connect)

Validation step What to check
Signature Verify with the AS's public key (JWKS endpoint)
iss (issuer) Must exactly match the expected Authorization Server
aud (audience) Must contain your own client_id
exp (expiration) Token must not be expired
iat (issued at) Not too far in the past (clock skew tolerance max 5 min)
nonce Must match the nonce you sent in the authorization request
azp (authorized party) If present, must be your own client_id
alg (algorithm) Must be RS256 or ES256, never none or HS256 with public keys

Token storage

Storage location Safe? Reason
Server-side session Yes Token does not leave the server
httpOnly, Secure, SameSite cookie Yes Not accessible via JavaScript
localStorage No Accessible via XSS
sessionStorage No Accessible via XSS
URL (query parameter / fragment) No Visible in logs, history, Referer headers
Hidden form field No Accessible via DOM manipulation

The golden rule: if JavaScript can reach it, an XSS attacker can reach it. Use server-side sessions or httpOnly cookies.

Scope minimalism

Principle of least privilege

Only request the scopes your application actually needs. Not "just in case", not "maybe later", not "it only works if I request everything."

# Wrong: scope=openid profile email drive calendar contacts
# Right:  scope=openid email

Broad scopes increase risk in case of token theft and reduce user trust.

Incremental authorization

Request basic scopes at the first login (openid email profile), and request additional scopes when the user activates a feature that requires them. This increases user trust and limits the blast radius upon a compromise.

State parameter

The state parameter protects the OAuth flow against CSRF attacks. Without state, an attacker can make a victim log in to the attacker's account (login CSRF).

How state works

  1. Client generates a cryptographically random state value
  2. Client stores it in the server-side session
  3. Client sends state along in the authorization request
  4. Authorization Server returns the same state in the redirect
  5. Client verifies: state in redirect == state in session

Generation and validation

import secrets
from flask import session, abort

def generate_state() -> str:
    state = secrets.token_urlsafe(32)
    session["oauth_state"] = state
    return state

def validate_state(received_state: str) -> None:
    expected = session.pop("oauth_state", None)
    if not expected or not secrets.compare_digest(expected, received_state):
        abort(403, "State mismatch -- possible CSRF attack")

Use secrets.compare_digest() for timing-safe comparison. The state can also contain additional data (e.g. a return URL), provided it is cryptographically signed with an HMAC.

OpenID Connect specifics

OIDC adds an identity layer on top of OAuth 2.0. Where OAuth only says "this client may retrieve data", OIDC also says "this user is Jane with email jane@example.com." On every login, the ID token must be fully validated:

from jose import jwt, JWTError
import requests

ISSUER    = "https://idp.example.com"
CLIENT_ID = "your-client-id"
JWKS_URL  = f"{ISSUER}/.well-known/jwks.json"

def validate_id_token(id_token: str, expected_nonce: str) -> dict:
    jwks = requests.get(JWKS_URL, timeout=5).json()  # cache in production!
    try:
        claims = jwt.decode(id_token, jwks,
            algorithms=["RS256", "ES256"],  # never 'none' or 'HS256'
            audience=CLIENT_ID, issuer=ISSUER)
    except JWTError as e:
        abort(401, f"ID token validation failed: {e}")

    if claims.get("nonce") != expected_nonce:         # replay protection
        abort(401, "Nonce mismatch in ID token")
    if "azp" in claims and claims["azp"] != CLIENT_ID:
        abort(401, "Unexpected authorized party")
    return claims

Discovery endpoint

Every OIDC provider publishes configuration at a fixed path:

GET https://idp.example.com/.well-known/openid-configuration

This returns JSON with issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, supported scopes, response types, and signing algorithms. Use this endpoint to dynamically configure endpoints rather than hardcoding URLs.

Userinfo endpoint security

  • Trust the ID token for authentication, not the userinfo response
  • Validate that sub in userinfo matches sub in the ID token
  • Call the endpoint server-side, never from client-side JavaScript

Common mistakes

Mistake Risk Solution
No state parameter CSRF: attacker can make victim log in to wrong account Generate and validate cryptographically random state
Open redirect URI Authorization code theft: code is sent to attacker Exact URI matching, no wildcards
Tokens in URL parameters Token leakage via Referer header, browser history, server logs Use back-channel token exchange, Authorization header
No PKCE Authorization code interception on public clients Implement PKCE (S256) for all clients
Long-lived access tokens Larger window for abuse upon token theft Max 5-15 minute lifetime, use refresh tokens
No audience validation Token confusion: token meant for another client is accepted Check aud claim in ID token and access token
Using Implicit flow Token leakage, no refresh tokens, deprecated Migrate to Authorization Code + PKCE
Accepting alg: none Attacker can forge their own ID tokens Whitelist only RS256/ES256, always verify signature
Refresh token without rotation Stolen refresh token grants permanent access Rotate refresh tokens on each use
Client secret in frontend Secret is public, attacker can impersonate your app Client secret server-side only, or use PKCE without secret
No nonce in OIDC Replay attack with intercepted ID token Generate and validate nonce for each authorization request
Not caching JWKS Performance issues, vulnerable to DoS on IdP Cache JWKS with TTL (e.g. 24 hours), refresh on kid mismatch

Checklist

# Measure Priority Status
1 Use Authorization Code flow (not Implicit) Critical [ ]
2 Implement PKCE (S256) for all clients Critical [ ]
3 Validate redirect URI with exact string match Critical [ ]
4 Generate and validate state parameter Critical [ ]
5 Validate ID token: issuer, audience, expiry, signature, nonce Critical [ ]
6 Store tokens server-side (not in localStorage/sessionStorage) Critical [ ]
7 Limit access token lifetime to 5-15 minutes High [ ]
8 Implement refresh token rotation High [ ]
9 Request minimal scopes (principle of least privilege) High [ ]
10 Use HTTPS for all redirect URIs Critical [ ]
11 Keep client secret server-side only Critical [ ]
12 Whitelist JWT algorithms (RS256/ES256), block none and HS256 Critical [ ]
13 Cache JWKS with TTL, refresh on unknown kid Medium [ ]
14 Implement token revocation (RFC 7009) High [ ]
15 Use discovery endpoint for dynamic configuration Medium [ ]
16 Validate sub consistency between ID token and userinfo Medium [ ]
17 Log all token exchange events for audit trail Medium [ ]
18 Implement incremental authorization for additional scopes Low [ ]

OAuth 2.0 was intended as a simple delegation framework. "I want this app to be able to read my Google Calendar, without giving my Google password to that app." Clear. Elegant. And then came the RFCs. RFC 6749, 6750, 7636, 7009, 7662, 8252, 9126, 9207, 9700. Plus OpenID Connect Core, Discovery, and Dynamic Registration. More than two thousand pages of specification for what started as "let me read your calendar."

Most "Login with Google" buttons are cargo-culted from Stack Overflow, from a 2019 tutorial that uses Implicit flow – a flow that is now officially deprecated. But it works, so it's safe, right? Meanwhile, behind that friendly blue button sits an ocean of complexity: state parameters, PKCE, redirect URI validation, token rotation, nonce checks, JWKS caching, audience validation. If one of those dozens of details goes wrong, you have an account takeover that you only discover when a researcher emails you – or worse, when it's on Twitter.

And then the consent screen. "This app wants access to your profile, your email, your contacts, your calendar, your Drive, your location history, and your firstborn child." The user clicks "Allow" because that's the only thing standing between them and the free service. OAuth is the Stockholm syndrome of web security. We hate how complex it is. We don't understand half the RFCs. But we can't do without it anymore.

Summary

Use exclusively the Authorization Code flow with PKCE for all clients – Implicit flow is deprecated and insecure. Validate redirect URIs with exact string matching, generate cryptographically random state parameters against CSRF, and limit scopes to the absolute minimum. Store tokens server-side, limit access token lifetime to at most 15 minutes, and implement refresh token rotation. Validate ID tokens fully: signature, issuer, audience, expiry, and nonce. OAuth and OpenID Connect are powerful frameworks, but their security depends on correctly implementing every detail – and there are more of them than most developers suspect.

Op de hoogte blijven?

Ontvang maandelijks cybersecurity-inzichten in je inbox.

← Web Security ← Home