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
- Client generates a random
code_verifier(43-128 characters, URL-safe) - Client computes
code_challenge = BASE64URL(SHA256(code_verifier)) - Client sends
code_challengealong in the authorization request - Authorization Server stores the challenge alongside the code
- Client sends
code_verifierwhen exchanging the code - 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
- Exact match – redirect URI must exactly match the registered URI
- No wildcards –
https://*.example.com/callbackis not safe - No open redirects –
https://app.example.com/redirect?url=evil.comis fatal - 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_URISDo 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
- Client generates a cryptographically random
statevalue - Client stores it in the server-side session
- Client sends
statealong in the authorization request - Authorization Server returns the same
statein the redirect - Client verifies:
statein redirect ==statein 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 claimsDiscovery 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
subin userinfo matchessubin 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.
Further reading in the knowledge base
These articles in the portal give you more background and practical context:
- APIs — the invisible glue of the internet
- SSL/TLS — why that padlock in your browser matters
- Encryption — the art of making things unreadable
- Password hashing — how websites store your password
- Penetration testing vs. vulnerability scans
You need an account to access the knowledge base. Log in or register.
Related security measures
These articles offer additional context and depth: