OAuth & OpenID Connect
Login Zonder Losse Eindjes
Veilige webontwikkeling draait niet om extra frictie, maar om betere defaults in ontwerp, code en releaseflow.
In OAuth & OpenID Connect telt vooral robuuste identiteit: sterke authenticatie, betrouwbaar sessiebeheer en minimaal privilege.
Dat maakt security minder een losse controle achteraf en meer een standaardkwaliteit van je product.
Directe maatregelen (15 minuten)
Waarom dit telt
De kern van OAuth & OpenID Connect is risicoreductie in de praktijk. Technische context ondersteunt de maatregelkeuze, maar implementatie en borging staan centraal.
OAuth 2.0 in het kort
OAuth 2.0 (RFC 6749) is een autorisatie-framework, geen authenticatie-protocol. Het stelt een applicatie in staat om namens een gebruiker toegang te krijgen tot een API, zonder dat de gebruiker zijn wachtwoord aan die applicatie geeft.
Rollen
| Rol | Omschrijving | Voorbeeld |
|---|---|---|
| Resource Owner | De gebruiker die toegang verleent | Jij, de eindgebruiker |
| Client | De applicatie die toegang wil | Je webapplicatie of mobiele app |
| Authorization Server (AS) | Geeft tokens uit na toestemming | Google Accounts, Azure AD, Keycloak |
| Resource Server (RS) | De API die beschermde data levert | Google Calendar API, Microsoft Graph |
Authorization Code flow
Dit is de aanbevolen flow voor vrijwel alle scenario’s. Het diagram:
Resource Owner Client (webapp) Auth Server Resource Server
| | | |
| 1. Klik "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 aan user | | |
|<---------------------| | |
De kern: de authorization code gaat via de browser (front-channel), maar het access token wordt server-to-server (back-channel) opgehaald.
Waarom Implicit flow deprecated is
De Implicit flow (response_type=token) levert het access
token direct in de URL-fragment. Problemen: token zichtbaar in browser
history/logs, geen refresh tokens, leakage via Referer headers, geen
client-authenticatie, niet combineerbaar met PKCE. RFC 9700 is helder:
gebruik Implicit flow niet meer. Gebruik Authorization
Code + PKCE voor alle clients, inclusief SPA’s.
PKCE (Proof Key for Code Exchange)
PKCE (RFC 7636, uitgesproken als “pixy”) beschermt de authorization code tegen onderschepping. Oorspronkelijk ontworpen voor mobile/native apps (public clients), maar nu verplicht voor alle clients – inclusief confidential clients met een client_secret.
Waarom verplicht voor alle clients
Zonder PKCE kan een aanvaller die de authorization code onderschept deze inwisselen voor een access token. PKCE maakt de code waardeloos zonder de bijbehorende code_verifier, die alleen de legitieme client kent.
Hoe het werkt
- Client genereert een random
code_verifier(43-128 tekens, URL-safe) - Client berekent
code_challenge = BASE64URL(SHA256(code_verifier)) - Client stuurt
code_challengemee in de authorization request - Authorization Server slaat de challenge op bij de code
- Client stuurt
code_verifiermee bij het inwisselen van de code - AS verifieert:
BASE64URL(SHA256(code_verifier)) == opgeslagen challenge
Code: Python/Flask PKCE implementatie
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 tekens
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 sessie!
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 -- mogelijke 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")
# Valideer id_token (zie sectie 16.7)
return redirect(url_for("dashboard"))Gebruik altijd S256 als
code_challenge_method. De plain methode biedt
geen bescherming en wordt door sommige Authorization Servers
geweigerd.
Redirect URI validatie
De redirect URI is het meest misbruikte onderdeel van OAuth. Een open redirect in de redirect URI stelt een aanvaller in staat om de authorization code te stelen.
Regels
- Exacte match – redirect URI moet exact overeenkomen met geregistreerde URI
- Geen wildcards –
https://*.example.com/callbackis niet veilig - Geen open redirects –
https://app.example.com/redirect?url=evil.comis fataal - HTTPS verplicht – behalve voor localhost tijdens development
Veelgemaakte fouten
| Patroon | Veilig? | Reden |
|---|---|---|
https://app.example.com/callback |
Ja | Exacte match, specifiek pad |
https://app.example.com/callback?extra=1 |
Nee | Query parameters kunnen gemanipuleerd worden |
https://*.example.com/callback |
Nee | Subdomain wildcard: aanvaller kan evil.example.com
registreren |
https://app.example.com/ |
Nee | Te breed, elk pad onder / matcht |
https://app.example.com/redirect?url=... |
Nee | Open redirect, code lekt naar externe URL |
http://app.example.com/callback |
Nee | Geen TLS, code onderschepbaar |
https://app.example.com/callback/../admin |
Nee | Path traversal, URI-normalisatie kan misleiden |
localhost:8080/callback |
Alleen dev | Acceptabel tijdens development, nooit in productie |
Server-side validatie
ALLOWED_REDIRECT_URIS = {
"https://app.example.com/callback",
"https://app.example.com/auth/callback",
}
def validate_redirect_uri(uri: str) -> bool:
"""Valideer redirect URI met exacte string-matching."""
# Geen normalisatie, geen parsing -- exacte match
return uri in ALLOWED_REDIRECT_URISGebruik geen regex of substring matching voor
redirect URI validatie.
startswith("https://app.example.com") matcht ook
https://app.example.com.evil.com.
Token-beveiliging
Access tokens
| Eigenschap | Aanbeveling |
|---|---|
| Levensduur | 5-15 minuten |
| Formaat | Opaque string of signed JWT |
| Inhoud | Geen gevoelige data (PII, wachtwoorden) |
| Transport | Alleen via Authorization: Bearer header, nooit in
URL |
| Opslag client-side | Niet – sla op in server-side sessie |
| Intrekking | Token introspection endpoint of korte levensduur als alternatief |
Korte levensduur is de belangrijkste maatregel. Een gestolen token dat 5 minuten geldig is, beperkt de schade drastisch.
Refresh tokens
| Eigenschap | Aanbeveling |
|---|---|
| Levensduur | Uren tot dagen (afhankelijk van risicoprofiel) |
| Rotation | Geef bij elk gebruik een nieuw refresh token uit, invalideer het oude |
| Opslag | Server-side, encrypted, nooit in de browser |
| Binding | Bind aan client_id, user, en bij voorkeur aan device/IP |
| Intrekking | Revocation endpoint (RFC 7009) implementeren |
Refresh token rotation detecteert diefstal: als een gestolen refresh token wordt gebruikt nadat de legitieme client al een nieuw token heeft opgehaald, worden beide tokens ongeldig.
ID tokens (OpenID Connect)
| Validatiestap | Wat controleren |
|---|---|
| Signature | Verifieer met de public key van de AS (JWKS endpoint) |
iss (issuer) |
Moet exact overeenkomen met de verwachte Authorization Server |
aud (audience) |
Moet je eigen client_id bevatten |
exp (expiration) |
Token mag niet verlopen zijn |
iat (issued at) |
Niet te ver in het verleden (clock skew tolerantie max 5 min) |
nonce |
Moet overeenkomen met de nonce die je in de authorization request stuurde |
azp (authorized party) |
Als aanwezig, moet je eigen client_id zijn |
alg (algorithm) |
Moet RS256 of ES256 zijn, nooit none
of HS256 met publieke keys |
Token opslag
| Opslaglocatie | Veilig? | Reden |
|---|---|---|
| Server-side sessie | Ja | Token verlaat de server niet |
| httpOnly, Secure, SameSite cookie | Ja | Niet toegankelijk via JavaScript |
| localStorage | Nee | Toegankelijk via XSS |
| sessionStorage | Nee | Toegankelijk via XSS |
| URL (query parameter / fragment) | Nee | Zichtbaar in logs, history, Referer headers |
| Hidden form field | Nee | Toegankelijk via DOM manipulation |
De gouden regel: als JavaScript erbij kan, kan een XSS-aanvaller erbij. Gebruik server-side sessies of httpOnly cookies.
Scope-minimalisme
Principle of least privilege
Vraag alleen de scopes aan die je applicatie daadwerkelijk nodig heeft. Niet “voor het geval dat”, niet “misschien later”, niet “het werkt alleen als ik alles aanvraag.”
# Fout: scope=openid profile email drive calendar contacts
# Goed: scope=openid email
Brede scopes verhogen het risico bij token theft en verlagen het gebruikersvertrouwen.
Incremental authorization
Vraag basis-scopes aan bij de eerste login
(openid email profile), en vraag aanvullende scopes aan
wanneer de gebruiker een functie activeert die ze nodig heeft. Dit
vergroot het gebruikersvertrouwen en beperkt de blast radius bij een
compromis.
State parameter
De state parameter beschermt de OAuth flow tegen
CSRF-aanvallen. Zonder state kan een aanvaller een slachtoffer laten
inloggen op het account van de aanvaller (login CSRF).
Hoe state werkt
- Client genereert een cryptografisch random
statewaarde - Client slaat deze op in de server-side sessie
- Client stuurt
statemee in de authorization request - Authorization Server stuurt dezelfde
stateterug in de redirect - Client verifieert:
statein redirect ==statein sessie
Generatie en validatie
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 -- mogelijke CSRF-aanval")Gebruik secrets.compare_digest() voor timing-safe
vergelijking. De state kan ook extra data bevatten (bijv.
een return-URL), mits cryptografisch ondertekend met een HMAC.
OpenID Connect specifiek
OIDC voegt een identiteitslaag toe aan OAuth 2.0. Waar OAuth alleen zegt “deze client mag data ophalen”, zegt OIDC ook “deze gebruiker is Jan met e-mail jan@example.com.” Bij elke login moet de ID token volledig gevalideerd worden:
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 productie!
try:
claims = jwt.decode(id_token, jwks,
algorithms=["RS256", "ES256"], # nooit 'none' of 'HS256'
audience=CLIENT_ID, issuer=ISSUER)
except JWTError as e:
abort(401, f"ID token validatie mislukt: {e}")
if claims.get("nonce") != expected_nonce: # replay-bescherming
abort(401, "Nonce mismatch in ID token")
if "azp" in claims and claims["azp"] != CLIENT_ID:
abort(401, "Onverwachte authorized party")
return claimsDiscovery endpoint
Elke OIDC-provider publiceert configuratie op een vast pad:
GET https://idp.example.com/.well-known/openid-configuration
Dit retourneert JSON met issuer,
authorization_endpoint, token_endpoint,
userinfo_endpoint, jwks_uri, ondersteunde
scopes, response types, en signing algoritmes. Gebruik dit endpoint om
endpoints dynamisch te configureren in plaats van URL’s te
hardcoden.
Userinfo endpoint security
- Vertrouw het ID token voor authenticatie, niet het userinfo response
- Valideer dat
subin userinfo overeenkomt metsubin het ID token - Roep het endpoint server-side aan, nooit vanuit client-side JavaScript
Veelvoorkomende fouten
| Fout | Risico | Oplossing |
|---|---|---|
| Geen state parameter | CSRF: aanvaller kan slachtoffer laten inloggen op verkeerd account | Genereer en valideer cryptografisch random state |
| Open redirect URI | Authorization code diefstal: code wordt naar aanvaller gestuurd | Exacte URI matching, geen wildcards |
| Tokens in URL parameters | Token leakage via Referer header, browser history, server logs | Gebruik back-channel token exchange, Authorization
header |
| Geen PKCE | Authorization code interception op public clients | Implementeer PKCE (S256) voor alle clients |
| Langlevende access tokens | Groter window voor misbruik bij token theft | Max 5-15 minuten levensduur, gebruik refresh tokens |
| Geen audience validatie | Token confusion: token bedoeld voor andere client wordt geaccepteerd | Controleer aud claim in ID token en access token |
| Implicit flow gebruiken | Token leakage, geen refresh tokens, deprecated | Migreer naar Authorization Code + PKCE |
alg: none accepteren |
Aanvaller kan zelf ID tokens smeden | Whitelist alleen RS256/ES256, verifieer altijd signature |
| Refresh token zonder rotation | Gestolen refresh token geeft permanente toegang | Roteer refresh tokens bij elk gebruik |
| Client secret in frontend | Secret is publiek, aanvaller kan zich voordoen als je app | Client secret alleen server-side, of gebruik PKCE zonder secret |
| Geen nonce in OIDC | Replay attack met onderschept ID token | Genereer en valideer nonce bij elke authorization request |
| JWKS niet cachen | Performance issues, kwetsbaar voor DoS op IdP | Cache JWKS met TTL (bijv. 24 uur), refresh bij kid
mismatch |
Checklist
| # | Maatregel | Prioriteit | Status |
|---|---|---|---|
| 1 | Gebruik Authorization Code flow (niet Implicit) | Kritiek | [ ] |
| 2 | Implementeer PKCE (S256) voor alle clients | Kritiek | [ ] |
| 3 | Valideer redirect URI met exacte string match | Kritiek | [ ] |
| 4 | Genereer en valideer state parameter | Kritiek | [ ] |
| 5 | Valideer ID token: issuer, audience, expiry, signature, nonce | Kritiek | [ ] |
| 6 | Sla tokens server-side op (niet in localStorage/sessionStorage) | Kritiek | [ ] |
| 7 | Beperk access token levensduur tot 5-15 minuten | Hoog | [ ] |
| 8 | Implementeer refresh token rotation | Hoog | [ ] |
| 9 | Vraag minimale scopes aan (principle of least privilege) | Hoog | [ ] |
| 10 | Gebruik HTTPS voor alle redirect URI’s | Kritiek | [ ] |
| 11 | Client secret alleen server-side bewaren | Kritiek | [ ] |
| 12 | Whitelist JWT-algoritmes (RS256/ES256), blokkeer none
en HS256 |
Kritiek | [ ] |
| 13 | Cache JWKS met TTL, refresh bij onbekende kid |
Middel | [ ] |
| 14 | Implementeer token revocation (RFC 7009) | Hoog | [ ] |
| 15 | Gebruik discovery endpoint voor dynamische configuratie | Middel | [ ] |
| 16 | Valideer sub consistentie tussen ID token en
userinfo |
Middel | [ ] |
| 17 | Log alle token exchange events voor audit trail | Middel | [ ] |
| 18 | Implementeer incremental authorization voor aanvullende scopes | Laag | [ ] |
OAuth 2.0 was bedoeld als een simpel delegatie-framework. “Ik wil dat deze app mijn Google Agenda kan lezen, zonder dat ik mijn Google-wachtwoord aan die app geef.” Helder. Elegant. En toen kwamen de RFC’s. RFC 6749, 6750, 7636, 7009, 7662, 8252, 9126, 9207, 9700. Plus OpenID Connect Core, Discovery, en Dynamic Registration. Meer dan tweeduizend pagina’s specificatie voor wat begon als “laat me je agenda lezen.”
De meeste “Login met Google”-buttons zijn gecargo-cult van Stack Overflow, vanuit een tutorial uit 2019 die Implicit flow gebruikt – een flow die inmiddels officieel deprecated is. Maar het werkt, dus het is veilig, toch? Ondertussen zit achter die vriendelijke blauwe button een oceaan aan complexiteit: state parameters, PKCE, redirect URI validatie, token rotation, nonce checks, JWKS caching, audience validatie. Als een van die tientallen details fout gaat, heb je een account takeover die je pas ontdekt als een onderzoeker je mailt – of erger, als het op Twitter staat.
En dan de consent screen. “Deze app wil toegang tot je profiel, je e-mail, je contacten, je agenda, je Drive, je locatiegeschiedenis, en je eerstgeboren kind.” De gebruiker klikt “Toestaan” omdat dat het enige is wat tussen hen en de gratis dienst in staat. OAuth is het Stockholm-syndroom van web security. We haten hoe complex het is. We begrijpen niet de helft van de RFC’s. Maar we kunnen niet meer zonder.
Samenvatting
Gebruik uitsluitend de Authorization Code flow met PKCE voor alle clients – Implicit flow is deprecated en onveilig. Valideer redirect URI’s met exacte string matching, genereer cryptografisch random state parameters tegen CSRF, en beperk scopes tot het absolute minimum. Sla tokens server-side op, beperk access token levensduur tot maximaal 15 minuten, en implementeer refresh token rotation. Valideer ID tokens volledig: signature, issuer, audience, expiry, en nonce. OAuth en OpenID Connect zijn krachtige frameworks, maar hun beveiliging hangt af van het correct implementeren van elk detail – en dat zijn er meer dan de meeste ontwikkelaars vermoeden.
Verder lezen in de kennisbank
Deze artikelen in het portaal geven je meer achtergrond en praktische context:
- API's — de onzichtbare lijm van het internet
- SSL/TLS — waarom dat slotje in je browser ertoe doet
- Encryptie — de kunst van het onleesbaar maken
- Wachtwoord-hashing — hoe websites je wachtwoord opslaan
- Penetratietesten vs. vulnerability scans
Je hebt een account nodig om de kennisbank te openen. Inloggen of registreren.
Gerelateerde securitymaatregelen
Deze artikelen bieden aanvullende context en verdieping: