OAuth 2.x permet à une application d'accéder à vos données sans connaître votre mot de passe. C'est le standard utilisé quand une app demande "Se connecter avec Google" ou quand vos microservices communiquent entre eux. OAuth ne gère pas l'identité (c'est le rôle d'OIDC), il gère la délégation d'accès : "cette application peut lire mes fichiers, mais pas les supprimer".
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »Ce guide vous explique OAuth 2.x, le standard dominant pour sécuriser l'accès aux APIs. À la fin, vous saurez :
- Identifier les 4 acteurs OAuth et leur rôle
- Choisir le bon flow selon votre contexte (SPA, mobile, server-to-server)
- Implémenter PKCE correctement (obligatoire depuis OAuth 2.1)
- Gérer les tokens : durées de vie, rotation, révocation, introspection
Prérequis : avoir lu Les bases de l'IAM.
Le problème qu'OAuth résout
Section intitulée « Le problème qu'OAuth résout »Prenez le cas d'une application de suivi de commits qui doit lire vos dépôts GitHub. Avant OAuth, la seule façon de lui donner accès était de lui confier vos identifiants, avec trois conséquences :
- Donner votre mot de passe GitHub à l'application
- Faire confiance à cette application pour ne pas l'utiliser malicieusement
- Changer votre mot de passe pour révoquer l'accès (révoquant aussi tous les autres accès)
OAuth permet de déléguer un accès limité sans partager le mot de passe :
- L'application obtient un token avec des permissions spécifiques (scopes)
- Vous pouvez révoquer ce token sans toucher à votre mot de passe
- L'application ne voit jamais vos credentials
Les 4 acteurs OAuth
Section intitulée « Les 4 acteurs OAuth »La confusion la plus fréquente porte sur les deux serveurs. L'Authorization Server délivre les jetons et ne détient aucune donnée métier ; le Resource Server détient les données et ne sait pas authentifier un utilisateur, il se contente de valider le jeton qu'on lui présente. Cette séparation est ce qui permet à un même serveur d'autorisation de protéger des dizaines d'API différentes. Dans les déploiements réels, ces deux rôles sont souvent portés par des équipes distinctes, ce qui explique pourquoi les problèmes de scopes se découvrent tard.
| Acteur | Rôle | Exemple |
|---|---|---|
| Resource Owner | L'entité qui possède les données | Vous (votre compte GitHub) |
| Client | L'application qui veut accéder aux données | Application de suivi de commits |
| Authorization Server | Authentifie le Resource Owner, délivre les tokens | Keycloak, Auth0, GitHub OAuth |
| Resource Server | API qui protège les données, valide les tokens | API GitHub |
Schéma des interactions
Section intitulée « Schéma des interactions »Suivez les flèches dans l'ordre et repérez ce qui ne circule jamais : le mot de passe de l'utilisateur ne quitte pas l'Authorization Server, et le client ne le voit à aucun moment. Le second point à observer est que le client ne parle jamais directement au Resource Owner pour obtenir des droits : tout passe par une redirection du navigateur vers l'Authorization Server, ce qui est précisément la fenêtre que les attaques OAuth cherchent à exploiter.
Scopes : les permissions demandées
Section intitulée « Scopes : les permissions demandées »Les scopes définissent quelles permissions le client demande. L'utilisateur (Resource Owner) peut les accepter ou les refuser.
Exemples de scopes
Section intitulée « Exemples de scopes »Il n'existe aucune norme sur le nommage des scopes : chaque fournisseur invente le sien, et seuls les scopes OIDC (openid, profile, email) sont standardisés. Conséquence pratique, un scope valable chez un fournisseur n'a aucun sens chez un autre, et une erreur de nom se traduit par un jeton délivré avec moins de droits que prévu, sans message d'erreur explicite. Vérifiez toujours les noms exacts dans la documentation du fournisseur : GitHub, par exemple, expose repo, public_repo, repo:status et repo_deployment pour ses applications OAuth classiques, pas de couple lecture/écriture générique.
| Service | Scope | Permission accordée |
|---|---|---|
| GitHub | repo | Accès complet aux dépôts privés et publics |
| GitHub | public_repo | Accès aux seuls dépôts publics |
| GitHub | repo:status | Lecture et écriture des statuts de commit |
profile | Lire le profil public | |
email | Lire l'adresse email | |
| Keycloak | offline_access | Obtenir un refresh token |
Principe du moindre privilège
Section intitulée « Principe du moindre privilège »Demandez uniquement les scopes nécessaires. L'argument n'est pas seulement l'hésitation de l'utilisateur au moment du consentement : un jeton porteur d'un scope large reste dangereux pendant toute sa durée de vie, et un vol de jeton donne alors accès à tout ce que le scope couvre. Un jeton limité à la lecture ne permet ni suppression ni modification, quelle que soit la façon dont il a fuité.
Redirect URI et le flux de redirection
Section intitulée « Redirect URI et le flux de redirection »Le Redirect URI (ou callback URL) est l'URL vers laquelle l'Authorization Server redirige l'utilisateur après l'authentification.
Sécurité critique
Section intitulée « Sécurité critique »Ces trois règles visent toutes le même scénario d'attaque : détourner le code d'autorisation au moment de la redirection. La première est la plus souvent contournée en environnement de développement, où l'on ajoute un caractère joker pour tester plusieurs ports. Ce raccourci finit régulièrement en production, où il suffit alors d'enregistrer un sous-domaine correspondant au motif pour recevoir les codes des utilisateurs. L'exception http://localhost est explicitement prévue par les spécifications, mais elle ne vaut que pour le développement local.
| Règle | Pourquoi |
|---|---|
| Exact match obligatoire | Évite l'interception du code/token par un attaquant |
| Enregistré à l'avance | Le serveur ne redirige que vers les URIs connues |
| HTTPS obligatoire | Sauf http://localhost pour le développement |
Les flows OAuth : lequel choisir ?
Section intitulée « Les flows OAuth : lequel choisir ? »OAuth 2.0 définit plusieurs "flows" (ou "grants"). OAuth 2.1 en conserve seulement les sécurisés.
Le flow recommandé pour tout client interactif
Section intitulée « Le flow recommandé pour tout client interactif »Usage : SPA, mobile, applications web avec utilisateur
PKCE (Proof Key for Code Exchange, prononcé « pixy ») résout un problème précis : sur un mobile ou dans un navigateur, le code d'autorisation transite par une redirection que d'autres applications peuvent intercepter. Sans PKCE, quiconque capte ce code peut l'échanger contre un jeton. Avec PKCE, l'échange n'aboutit que si le demandeur présente le code_verifier d'origine, un secret qui n'a jamais circulé sur le réseau. Les six étapes ci-dessous se déroulent en quelques centaines de millisecondes, l'utilisateur ne perçoit que l'écran de connexion.
-
Génération du verifier
Le client génère un
code_verifieraléatoire (32 octets, base64url). -
Calcul du challenge
Le client calcule
code_challenge = BASE64URL(SHA256(code_verifier))et déclarecode_challenge_method=S256. La valeurplain, qui transmet le verifier tel quel, existe encore dans la RFC mais n'offre aucune protection. -
Requête d'autorisation
Le client redirige vers l'Authorization Server avec le
code_challenge. -
Authentification + consentement
L'utilisateur s'authentifie et accepte les scopes.
-
Retour avec le code
L'Authorization Server redirige vers le client avec un
authorization_code. -
Échange du code
Le client envoie le
code+code_verifieret reçoit les tokens.
// Étape 1-2 : Génération PKCEconst codeVerifier = crypto.randomBytes(32).toString('base64url');const codeChallenge = crypto .createHash('sha256') .update(codeVerifier) .digest('base64url');
// Étape 3 : Requête d'autorisationconst authUrl = new URL('https://auth.example.com/authorize');authUrl.searchParams.set('response_type', 'code');authUrl.searchParams.set('client_id', 'my-app');authUrl.searchParams.set('redirect_uri', 'https://myapp.com/callback');authUrl.searchParams.set('scope', 'openid profile email');authUrl.searchParams.set('state', generateRandomState()); // Anti-CSRFauthUrl.searchParams.set('code_challenge', codeChallenge);authUrl.searchParams.set('code_challenge_method', 'S256');
// Rediriger l'utilisateur vers authUrl// Étape 6 : Échange du code côté callbackconst tokenResponse = await fetch('https://auth.example.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authorizationCode, redirect_uri: 'https://myapp.com/callback', client_id: 'my-app', code_verifier: codeVerifier, // Prouve qu'on est l'initiateur }),});
const { access_token, refresh_token, id_token } = await tokenResponse.json();Pour le machine-to-machine (pas d'utilisateur)
Section intitulée « Pour le machine-to-machine (pas d'utilisateur) »Usage : microservices, jobs batch, APIs internes
Ce flow se distingue des autres par l'absence de redirection et de consentement : le client s'authentifie lui-même auprès de l'Authorization Server, qui lui remet un jeton portant sa propre identité. La conséquence à retenir est qu'il n'y a pas de refresh token ici, et c'est voulu. Un client capable de se réauthentifier à tout moment avec ses identifiants n'a aucun besoin d'en conserver un, et un refresh token supplémentaire ne ferait qu'agrandir la surface de vol.
# Le client s'authentifie directement avec ses credentialscurl -X POST https://auth.example.com/token \ -d "grant_type=client_credentials" \ -d "client_id=api-backend" \ -d "client_secret=secret" \ -d "scope=read:orders"Réponse :
{ "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600}À ne plus utiliser
Section intitulée « À ne plus utiliser »Ces deux flows ont été retirés parce que leur faille est structurelle, pas parce qu'une meilleure alternative est apparue. Implicit livrait le jeton directement dans le fragment d'URL, donc dans l'historique du navigateur et dans les journaux d'accès des serveurs intermédiaires. ROPC obligeait l'application à manipuler le mot de passe de l'utilisateur, ce qui annule tout l'intérêt d'OAuth et empêche l'authentification multifacteur. Si vous en trouvez encore dans votre code, le remplacement passe dans les deux cas par Authorization Code + PKCE.
| Flow | Problème | Alternative |
|---|---|---|
| Implicit | Token exposé dans l'URL (fragment), pas de refresh token | Authorization Code + PKCE |
| Resource Owner Password (ROPC) | L'application voit le mot de passe | Authorization Code + PKCE |
Paramètres de sécurité obligatoires
Section intitulée « Paramètres de sécurité obligatoires »Ces trois paramètres protègent contre trois attaques distinctes et ne se remplacent pas. Le state défend la session du navigateur contre un déclenchement forcé du flow par un tiers, le code_challenge défend le code d'autorisation contre l'interception, le nonce défend l'ID token OIDC contre le rejeu. Une erreur fréquente consiste à croire que PKCE dispense du state : ce n'est pas le cas, les deux couvrent des étapes différentes du parcours et les bonnes pratiques de sécurité OAuth demandent les deux.
| Paramètre | Rôle | Quand l'utiliser |
|---|---|---|
state | Anti-CSRF, lie la requête à la session | Toujours |
code_challenge | PKCE, protège contre l'interception | Toujours (OAuth 2.1) |
nonce | Anti-replay pour OIDC | Avec OIDC |
Validation du state
Section intitulée « Validation du state »Le point à retenir de ce code n'est pas la génération mais la comparaison au retour. Un state généré puis jamais vérifié ne sert à rien, et c'est l'oubli le plus courant en revue de code. Notez également le stockage : sessionStorage est cloisonné par onglet, ce qui suffit ici car le flow se déroule dans un seul onglet ; avec localStorage, deux connexions lancées en parallèle s'écraseraient mutuellement.
// Avant la redirectionconst state = crypto.randomBytes(16).toString('hex');sessionStorage.setItem('oauth_state', state);
// Au retour du callbackconst returnedState = new URLSearchParams(location.search).get('state');const savedState = sessionStorage.getItem('oauth_state');
if (returnedState !== savedState) { throw new Error('State mismatch - possible CSRF attack');}Gestion des tokens
Section intitulée « Gestion des tokens »Access Token vs Refresh Token
Section intitulée « Access Token vs Refresh Token »La colonne à lire en premier est Stockage, car c'est là que se jouent la plupart des compromissions. Un jeton placé dans localStorage est lisible par n'importe quel script injecté sur la page, donc une faille XSS suffit à l'exfiltrer. Garder l'access token en mémoire JavaScript et le refresh token dans un cookie HttpOnly met les deux hors de portée du code de page. La différence de durée de vie découle de cette exposition : l'access token circule à chaque appel d'API, donc il doit expirer vite.
| Token | Durée de vie | Usage | Stockage |
|---|---|---|---|
| Access Token | Court (5-60 min) | Appels API | Mémoire (SPA), secure storage (mobile) |
| Refresh Token | Long (heures/jours) | Obtenir un nouveau access token | Cookie HttpOnly, secure storage |
Refresh : obtenir un nouveau token
Section intitulée « Refresh : obtenir un nouveau token »Le commentaire de la dernière ligne signale le piège principal de cette requête. Si le serveur pratique la rotation, la réponse contient un nouveau refresh token et l'ancien devient invalide immédiatement. Une application qui ignore ce champ et continue d'utiliser le jeton initial verra ses renouvellements échouer, puis, sur les serveurs qui détectent la réutilisation, se fera révoquer toute la chaîne de jetons et déconnectera l'utilisateur.
const refreshResponse = await fetch('https://auth.example.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: currentRefreshToken, client_id: 'my-app', }),});
const { access_token, refresh_token } = await refreshResponse.json();// Attention : refresh_token peut être nouveau (rotation)Rotation des refresh tokens
Section intitulée « Rotation des refresh tokens »La rotation émet un nouveau refresh token à chaque utilisation. Si un token est volé et utilisé :
- L'attaquant obtient un nouveau token
- La victime utilise l'ancien token (invalide)
- Le serveur détecte l'anomalie et révoque toute la chaîne
Révocation
Section intitulée « Révocation »La révocation, définie par la RFC 7009, est le seul moyen de couper un accès avant l'expiration naturelle du jeton. Deux limites à connaître. Elle n'est pas obligatoire dans OAuth 2.0, tous les serveurs d'autorisation ne l'exposent pas. Et sur les jetons JWT validés localement par le Resource Server, révoquer côté serveur d'autorisation ne suffit pas : le jeton reste cryptographiquement valide jusqu'à son expiration, sauf si le Resource Server interroge l'introspection à chaque appel.
# Révoquer un token (côté client)curl -X POST https://auth.example.com/revoke \ -d "token=eyJhbGciOiJSUzI1NiIs..." \ -d "client_id=my-app"Introspection (tokens opaques)
Section intitulée « Introspection (tokens opaques) »Pour les jetons opaques (non-JWT), le Resource Server doit les valider auprès de l'Authorization Server. Ce détour réseau à chaque appel est le prix de la révocation immédiate : contrairement à un JWT auto-porteur, un jeton opaque ne veut rien dire hors du serveur qui l'a émis, donc un jeton révoqué cesse d'être accepté à la seconde suivante. Le champ à tester dans la réponse est active ; les autres champs ne doivent jamais être exploités si active vaut false.
curl -X POST https://auth.example.com/introspect \ -u api-resource-server:secret \ -d "token=abc123opaque"Réponse :
{ "active": true, "sub": "user-123", "scope": "read:orders", "exp": 1700000000}Vulnérabilités classiques
Section intitulée « Vulnérabilités classiques »Aucune de ces six failles n'exploite une faiblesse cryptographique : toutes viennent d'une configuration ou d'une implémentation incomplète. C'est la bonne nouvelle, elles se corrigent sans changer de bibliothèque. Les deux premières lignes sont les plus graves parce qu'elles mènent directement à un vol de jeton ; les deux dernières sont les plus répandues parce qu'elles résultent d'un choix de confort, une durée de vie allongée ou un scope élargi pour « éviter les problèmes ».
| Vulnérabilité | Cause | Protection |
|---|---|---|
| Authorization code interception | Pas de PKCE | PKCE obligatoire |
| Open redirect | Redirect URI avec wildcard | Exact match |
| Token leak via referer | Token dans l'URL | Ne pas mettre de token en query string |
| CSRF | Pas de state | Toujours envoyer et valider state |
| Token trop long | Durée de vie excessive | Access token < 1h, refresh token avec rotation |
| Scope trop large | Permissions excessives | Moindre privilège |
OAuth 2.0 vs OAuth 2.1
Section intitulée « OAuth 2.0 vs OAuth 2.1 »OAuth 2.1 ne crée aucun mécanisme nouveau : il consolide en un seul document les bonnes pratiques déjà publiées depuis 2012 dans des RFC séparées, et retire les éléments jugés dangereux. Autrement dit, une implémentation OAuth 2.0 rigoureuse est déjà presque conforme. Le tableau ci-dessous se lit donc comme une liste de choix qui étaient facultatifs et deviennent obligatoires, pas comme une migration technique.
| Changement | OAuth 2.0 | OAuth 2.1 |
|---|---|---|
| PKCE | Optionnel | Obligatoire pour tous les clients |
| Implicit flow | Disponible | Supprimé |
| ROPC | Disponible | Supprimé |
| Redirect URI | Matching flexible possible | Exact match obligatoire |
| Refresh token rotation | Non spécifié | Recommandé pour public clients |
Erreurs fréquentes
Section intitulée « Erreurs fréquentes »Ces cinq erreurs se rencontrent surtout sur les clients publics, c'est-à-dire les applications qui s'exécutent chez l'utilisateur et ne peuvent donc garder aucun secret : une SPA, une application mobile, un outil en ligne de commande. La deuxième ligne est la plus révélatrice d'une incompréhension du modèle : un client_secret embarqué dans du JavaScript livré au navigateur n'est pas un secret, il est lisible par tout visiteur. La réponse n'est pas de l'obfusquer mais de déclarer le client comme public et de s'appuyer sur PKCE.
| Erreur | Conséquence | Solution |
|---|---|---|
| Pas de PKCE sur client public | Vulnérable à l'interception | PKCE obligatoire |
client_secret côté client (SPA) | Secret exposé | Client public sans secret, utiliser PKCE |
| Refresh token stocké en localStorage | XSS = vol | Cookie HttpOnly ou mémoire + refresh silencieux |
Pas de validation du state | CSRF possible | Générer et valider un state aléatoire |
| Access token de 24h | 24h d'accès post-compromission | Token courts (15-60 min) + refresh |
À retenir
Section intitulée « À retenir »Références
Section intitulée « Références »- RFC 6749, OAuth 2.0 Authorization Framework
- RFC 7636, PKCE
- OAuth 2.1 Draft
- OAuth 2.0 Security Best Current Practice