jan-karel.nl
Home / Securitymaatregelen / Webbeveiliging / OAuth & OpenID Connect

OAuth & OpenID Connect

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

  1. Client genereert een random code_verifier (43-128 tekens, URL-safe)
  2. Client berekent code_challenge = BASE64URL(SHA256(code_verifier))
  3. Client stuurt code_challenge mee in de authorization request
  4. Authorization Server slaat de challenge op bij de code
  5. Client stuurt code_verifier mee bij het inwisselen van de code
  6. 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

  1. Exacte match – redirect URI moet exact overeenkomen met geregistreerde URI
  2. Geen wildcardshttps://*.example.com/callback is niet veilig
  3. Geen open redirectshttps://app.example.com/redirect?url=evil.com is fataal
  4. 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_URIS

Gebruik 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

  1. Client genereert een cryptografisch random state waarde
  2. Client slaat deze op in de server-side sessie
  3. Client stuurt state mee in de authorization request
  4. Authorization Server stuurt dezelfde state terug in de redirect
  5. Client verifieert: state in redirect == state in 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 claims

Discovery 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 sub in userinfo overeenkomt met sub in 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.

Op de hoogte blijven?

Ontvang maandelijks cybersecurity-inzichten in je inbox.

← Webbeveiliging ← Home