Ce qu’est OAuth 2.0, et ce qu’il n’est pas
OAuth 2.0 est un framework d’autorisation. Il permet à un utilisateur d’accorder à une application tierce un accès limité à ses ressources sans avoir à partager son mot de passe. Le résultat d’un flux réussi est un access token, et ce token décrit ce que le client est autorisé à faire, et non nécessairement qui est l’utilisateur.
C’est cette distinction qui induit constamment les gens en erreur. Le “Sign in with Google” est une combinaison d’OAuth 2.0 et d’OpenID Connect. La partie OAuth permet d’obtenir l’accès aux API de Google ; la partie OpenID Connect renvoie un ID token qui identifie réellement l’utilisateur. Si vous n’implémentez que OAuth et que vous traitez l’access token comme une preuve d’identité, vous avez mis en place un accès délégué en l’appelant authentification, ce qui constitue une erreur de catégorie avec des conséquences sur la sécurité.
Le protocole a été conçu pour répondre à un problème spécifique : permettre à un site d’impression photo de lire vos photos depuis un fournisseur de stockage sans jamais voir votre mot de passe de stockage. Gardez cette origine à l’esprit et les choix de conception deviendront logiques.
OpenID Connect : l’identité au premier plan
OpenID Connect est une fine couche d’identité superposée à OAuth 2.0. Elle ajoute le scope openid, un endpoint userinfo standard et, plus important encore, un ID token : un JWT dont les claims décrivent l’événement d’authentification.
{
"iss": "https://accounts.example.com",
"sub": "110169484474386276334",
"aud": "web-app",
"exp": 1760000000,
"iat": 1759999100,
"email": "[email protected]",
"email_verified": true,
"nonce": "n-0S6_WzA2Mj"
}
L’ID token est destiné au client, et non à l’API. Ne l’envoyez jamais à un serveur de ressources en guise d’access token. Vérifiez sa signature, iss, aud, exp et nonce avant de lui faire confiance, et utilisez sub, et non l’email, comme identifiant utilisateur stable. Les adresses email changent et peuvent être réattribuées ; le subject, lui, reste stable pendant toute la durée de vie du compte.
Les quatre rôles
OAuth définit quatre participants, et être précis sur chacun d’eux rend le reste du protocole évident.
- Resource owner (Propriétaire de la ressource) — l’utilisateur qui possède les données et accorde l’accès.
- Client — l’application qui demande l’accès, par exemple votre application web.
- Authorization server (Serveur d’autorisation) — délivre les tokens après avoir authentifié l’utilisateur et obtenu son consentement.
- Resource server (Serveur de ressources) — l’API qui accepte le token d’accès et renvoie les données.
Votre application web est le client. Google est l’authorization server. Les API de Google sont le resource server. L’utilisateur est le resource owner. Un seul fournisseur remplit souvent les deux rôles de serveur, c’est pourquoi cette distinction peut sembler théorique jusqu’à ce que vous construisiez la vôtre.
Types de grant (Grant types)
Un “grant type” est la méthode utilisée par un client pour obtenir un jeton. Quatre d’entre eux sont importants aujourd’hui.
- Authorization Code + PKCE — le choix par défaut pour le web, le mobile et les applications mono-page. L’utilisateur s’authentifie auprès du serveur d’autorisation, et le client échange un code à courte durée de vie contre des jetons.
- Client Credentials — pour les communications machine-to-machine. Aucun utilisateur n’est impliqué ; le client s’authentifie avec ses propres identifiants.
- Device Code — pour les appareils ayant des contraintes de saisie, comme les téléviseurs et les outils CLI.
- Refresh Token — ce n’est pas un moyen de se connecter, mais la méthode standard pour renouveler un jeton d’accès sans effectuer une nouvelle redirection.
Deux types de grants sont pratiquement obsolètes. Le flux implicit renvoyait les jetons directement dans le fragment de l’URL, les exposant ainsi à l’historique et aux referrers. Le grant password demandait au client de gérer le mot de passe de l’utilisateur, ce qui va à l’encontre même du principe d’OAuth. OAuth 2.1 supprime ces deux méthodes, et aucun nouveau système ne devrait les utiliser.
Authorization Code + PKCE
C’est le flux à maîtriser. Le client redirige l’utilisateur vers le serveur d’autorisation avec un challenge haché, l’utilisateur donne son consentement, le serveur redirige ensuite vers le client avec un code à usage unique, et le client échange ce code ainsi que le vérificateur original contre des tokens.
Le code est inutile sans le vérificateur ; ainsi, un attaquant qui intercepterait la redirection ne pourrait pas finaliser l’échange. C’est ce qui rend PKCE sécurisé, même pour les clients publics comme les applications mobiles et les applications monopage (SPA), qui sont incapables de conserver un secret.
Ce flux comprend deux redirections via le navigateur et une requête en back-channel. Les redirections sont visibles et peuvent être manipulées, tandis que l’échange est un POST direct de serveur à serveur qu’un attaquant ne peut pas observer. L’idée même de cette architecture est de maintenir les données sensibles sur le back-channel.
L’URI de redirection et le state
L’URI de redirection (redirect URI) est l’adresse vers laquelle le serveur d’autorisation renvoie l’utilisateur. Elle doit être enregistrée avec précision et correspondre exactement. Un rapprochement approximatif est la source de vulnérabilités de type “open-redirect” qui permettent de fuiter des codes d’autorisation vers des hôtes contrôlés par un attaquant. N’acceptez jamais d’URI de redirection provenant d’un paramètre de requête et n’autorisez pas les sous-domaines avec des caractères génériques (wildcards).
Le paramètre state est une valeur opaque générée par le client et vérifiée lors du retour du callback. Il protège le point de terminaison du callback contre les attaques CSRF. Un attaquant qui tromperait une victime pour qu’elle complète un flux avec le code de l’attaquant échouera à la vérification du state.
const state = randomBytes(16).toString("base64url");
session.oauthState = state;
// later, in the callback:
if (query.state !== session.oauthState) {
throw new Error("state_mismatch");
}
Pour OIDC, ajoutez un nonce et vérifiez-le à l’intérieur du jeton ID (ID token). Le state protège le callback du client contre le CSRF ; le nonce lie le jeton ID à cette requête spécifique et bloque le replay.
Le PKCE en détail
Le PKCE (Proof Key for Code Exchange), défini par la RFC 7636, est désormais obligatoire pour les clients publics. Il repose sur trois valeurs. Le client génère un code_verifier aléatoire, en dérive un code_challenge via un hachage SHA-256, envoie le challenge lors de la requête d’autorisation, puis envoie le verifier lors de la requête de jeton. Le serveur hache ensuite le verifier et compare les deux valeurs.
import { randomBytes, createHash } from "node:crypto";
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
Utilisez toujours la méthode S256. La méthode plain n’existe que pour des raisons de compatibilité et vide le processus de son sens, puisqu’un attaquant qui intercepte le challenge voit également le verifier. Stockez le verifier dans la session de l’utilisateur afin que le callback puisse le récupérer, et supprimez-le après une seule utilisation.
Construire la requête d’autorisation
La requête d’autorisation est une redirection du navigateur, il s’agit donc d’une requête GET avec des paramètres de requête. L’utilisateur voit l’écran de consentement du fournisseur, et non votre application.
export function buildAuthorizeUrl(state: string, challenge: string) {
const url = new URL("https://auth.example.com/authorize");
url.search = new URLSearchParams({
response_type: "code",
client_id: "web-app",
redirect_uri: "https://app.example.com/callback",
scope: "openid email profile offline_access",
state,
code_challenge: challenge,
code_challenge_method: "S256",
}).toString();
return url.toString();
}
response_type=code sélectionne le flux de code d’autorisation (authorization code flow). Le scope openid transforme la requête en une requête OIDC. offline_access est la convention courante pour demander un refresh token, et certains fournisseurs le conditionnent à une invite de consentement supplémentaire.
Échange de jetons
Le callback transmet code et state. Après avoir vérifié l’état (state), le client envoie le code via une requête POST au point de terminaison (endpoint) des jetons. Il s’agit d’un appel en arrière-plan (back-channel) depuis votre serveur, et non d’une redirection du navigateur.
export async function exchangeCode(code: string, verifier: string) {
const res = 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,
redirect_uri: "https://app.example.com/callback",
client_id: "web-app",
code_verifier: verifier,
}),
});
if (!res.ok) throw new Error("token_exchange_failed");
return res.json();
}
La réponse contient access_token, token_type, expires_in, généralement refresh_token, et pour OIDC, un id_token. Un client confidentiel, capable de conserver un secret, s’authentifie également lors de cette requête avec client_secret ou, idéalement, une assertion de clé privée. Un client public s’appuie uniquement sur PKCE.
Jetons d’accès, de rafraîchissement et d’identité (ID tokens)
Ces trois jetons ont des rôles distincts, et les confondre peut entraîner des bugs subtils.
- Access token — présenté au serveur de ressources. Il s’agit souvent d’un JWT, mais la spécification exige seulement qu’il soit opaque pour le client. Il peut s’agir d’une chaîne aléatoire que le serveur recherche en base.
- Refresh token — présenté uniquement au serveur d’autorisation pour obtenir un nouvel access token. Il a une durée de vie longue et est extrêmement sensible.
- ID token — un JWT OIDC qui indique au client qui vient de se connecter. Il n’est jamais envoyé à une API.
Considérez l’access token comme un titre porteur : toute personne le détenant peut l’utiliser. Maintenez des durées de vie courtes, demandez les scopes les plus restreints possibles, et laissez le refresh token gérer la relation à long terme. Le format du jeton lui-même est détaillé dans le guide JWT.
Scopes et consentement
Les scopes expriment ce que le client demande. Le serveur d’autorisation les présente à l’utilisateur sous forme d’écran de consentement et encode le sous-ensemble accordé dans l’access token.
Demandez le minimum. Une application de calendrier qui demande un accès complet à la boîte mail fera fuir les utilisateurs et augmentera l’impact potentiel d’une faille de sécurité. Les fournisseurs publient également des scopes réservés : openid est requis pour OIDC, et offline_access contrôle généralement les refresh tokens.
Les scopes ne sont pas des rôles. Un scope décrit une capacité demandée par le client pour cet accord ; un rôle décrit ce que l’utilisateur représente au sein de votre système. Effectuez la correspondance entre les deux sur votre serveur, et ne supposez jamais que les scopes d’un fournisseur disent quoi que ce soit sur votre propre modèle d’autorisation. Pour cet aspect du problème, consultez la section RBAC.
Appeler le serveur de ressources
Une fois le jeton d’accès obtenu, les appels à l’API le transmettent dans l’en-tête Authorization en utilisant le schéma Bearer.
const res = await fetch("https://api.example.com/me", {
headers: { authorization: `Bearer ${accessToken}` },
});
Le serveur de ressources valide le jeton, soit en vérifiant le JWT localement, soit par introspection, vérifie le scope, puis renvoie les données. Il ne voit jamais le refresh token ni l’ID token, et doit les rejeter s’il les reçoit.
Introspection et révocation
Tous les jetons d’accès ne sont pas des JWT. Lorsqu’un jeton est opaque, le serveur de ressources demande au serveur d’autorisation s’il est toujours valide via l’introspection de jeton (token introspection), définie par la RFC 7662.
POST /introspect HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <client credentials>
token=2YotnFZFEjr1zCsicMWpAA
L’introspection retourne active, ainsi que le scope, le sujet et la date d’expiration. Cette méthode est faisant foi mais nécessite un appel réseau ; il est donc conseillé de mettre le résultat en cache pendant quelques secondes.
La révocation, définie par la RFC 7009, permet à un client d’indiquer au serveur d’autorisation d’invalider un jeton, généralement lors de la déconnexion.
await fetch("https://auth.example.com/revoke", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token: refreshToken, client_id: "web-app" }),
});
Révocquez systématiquement les refresh tokens lors de la déconnexion, et gardez à l’esprit que la révocation d’un refresh token n’invalide pas toujours les jetons d’accès déjà émis. C’est la courte durée de vie des jetons d’accès qui permet à la révocation de paraître immédiate.
Machine-to-machine : client credentials
Lorsqu’aucun utilisateur n’est impliqué, le client agit en son nom propre. Il s’authentifie auprès du point de terminaison du jeton (token endpoint) et reçoit un jeton d’accès limité à ses propres permissions.
const res = await fetch("https://auth.example.com/token", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
},
body: new URLSearchParams({
grant_type: "client_credentials",
scope: "reports:read",
}),
});
Il n’y a pas d’écran de consentement ni de jeton de rafraîchissement (refresh token) ; le service demande simplement un nouveau jeton lorsque l’ancien expire. Privilégiez l’authentification client via JWT à clé privée plutôt qu’un secret partagé lorsque le fournisseur le permet, et renouvelez vos secrets régulièrement. C’est le même cas d’usage que celui des clés API à longue durée de vie, mais avec une expiration et un périmètre (scoping) standardisés, comme détaillé dans la section API Keys.
Quand ne pas utiliser OAuth
OAuth sert à déléguer l’accès à un tiers. Si votre propre application web authentifie ses propres utilisateurs auprès de son propre backend, vous n’avez pas besoin d’OAuth. Un cookie de session, ou un JWT first-party que vous émettez vous-même, est plus simple et plus facile à sécuriser. Placer un serveur d’autorisation entre votre formulaire de connexion et votre base de données apporte de la complexité, pas de la sécurité.
Tournez-vous vers OAuth lorsque vous intégrez un fournisseur externe, que vous agissez en tant que fournisseur pour des clients tiers, ou que vous avez besoin d’un standard pour l’accès machine-to-machine. Sinon, commencez par l’Authentification par Session et n’ajoutez OAuth que lorsqu’un réel besoin de délégation apparaît. Comme chaque flux ici repose sur des redirections et des headers, le guide HTTP est un compagnon utile.
Pièges courants
- Implicit flow — les jetons dans le fragment de l’URL fuitent via l’historique, les logs et les referrers. Utilisez l’Authorization Code avec PKCE.
- Jetons dans le localStorage — une faille XSS se transforme alors en prise de contrôle de compte. Conservez les jetons dans des cookies httpOnly ou des sessions côté serveur.
- Redirections ouvertes (Open redirects) — une correspondance trop permissive des URI de redirection permet à un attaquant de voler des codes. Faites correspondre l’URI enregistrée exactement.
- Omission du paramètre state — sans lui, le callback est vulnérable aux attaques CSRF.
- Scopes trop larges — demander tous les accès rend le consentement insignifiant et aggrave l’impact des failles de sécurité.
- Access tokens à longue durée de vie — ils ne peuvent pas être révoqués rapidement. Gardez-les courts et effectuez une rotation des refresh tokens.
- Utilisation de l’ID token comme identifiant d’API — il est destiné au client, et non au serveur de ressources.
Choisir le flux approprié
La plupart des décisions d’intégration se résument à deux questions : y a-t-il un utilisateur et le client peut-il garder un secret ?
- Un utilisateur est présent, le client est public (SPA, application mobile, application desktop) : Authorization Code avec PKCE.
- Un utilisateur est présent, le client est confidentiel (application web avec SSR) : Authorization Code avec PKCE plus authentification du client.
- Pas d’utilisateur, le client est un service (cron job, microservice) : Client Credentials.
- L’appareil n’a ni navigateur ni clavier (TV, CLI) : Device Code.
Tout le reste est obsolète. Si un fournisseur ne documente que le flux implicite, considérez cela comme un signal d’alarme et vérifiez si PKCE est disponible.
Le pattern backend-for-frontend
L’endroit le plus sûr pour stocker des tokens est un serveur que vous contrôlez. Le pattern backend-for-frontend place un serveur léger entre le navigateur et le serveur d’autorisation : le navigateur reçoit un cookie de session, et le serveur conserve les access et refresh tokens.
app.get("/callback", async (req, res) => {
if (req.query.state !== req.session.oauthState) {
return res.status(400).json({ error: "state_mismatch" });
}
const tokens = await exchangeCode(
String(req.query.code),
req.session.codeVerifier,
);
req.session.tokens = tokens;
delete req.session.oauthState;
delete req.session.codeVerifier;
res.redirect("/dashboard");
});
Le navigateur ne voit jamais de token, ce qui empêche le vol via XSS, et le serveur peut effectuer le rafraîchissement silencieusement. Le compromis réside dans l’ajout d’un saut réseau et d’un serveur à gérer, ce qui est généralement bien moins coûteux que l’incident que vous évitez.
Clients natifs et mobiles
Les applications natives ne peuvent pas conserver de secret client, l’utilisation de PKCE est donc indispensable. Elles sont également confrontées à un problème de redirection : un schéma personnalisé tel que myapp://callback peut être usurpé par une application malveillante. La solution moderne consiste à utiliser un onglet du navigateur système, soit via l’API de session d’authentification de la plateforme, soit via un navigateur intégré qui partage les cookies avec le système.
N’utilisez jamais de WebView intégré pour OAuth. L’application pourrait lire le mot de passe de l’utilisateur, ce qui briserait la frontière de confiance que le protocole entier vise à protéger. Ouvrez le navigateur système, recevez le callback et conservez les tokens dans le stockage sécurisé de la plateforme.
Héberger votre propre serveur d’autorisation
Il n’est pas nécessaire d’en construire un. Des serveurs établis tels que Keycloak, Ory Hydra et les fournisseurs d’identité cloud implémentent déjà le protocole, les écrans de consentement, la gestion des clés et le stockage des tokens pour vous. Développer votre propre solution est un projet de plusieurs mois dont les modes de défaillance sont tous critiques pour la sécurité.
Si vous choisissez tout de même d’héberger le vôtre, vos responsabilités minimales sont : la correspondance exacte des URI de redirection, l’application du PKCE, des durées de vie courtes pour les access-tokens, la rotation des refresh-tokens avec détection de réutilisation, un endpoint JWKS et la révocation. Oubliez l’un de ces éléments et vous aurez déployé une vulnérabilité, pas une fonctionnalité.
Tester une intégration OAuth
Les tests de bout en bout (E2E) pour OAuth sont lents et fragiles car ils dépendent d’un fournisseur réel et d’un navigateur réel. Privilégiez plutôt des tests par couches.
- Effectuez des tests unitaires sur la construction des URL, la dérivation PKCE et la comparaison d’état (state) en tant que fonctions pures.
- Effectuez des tests d’intégration pour l’échange de jetons (token exchange) via un serveur d’autorisation simulé (mock) qui renvoie des jetons prédéfinis.
- Conservez un seul test de fumée (smoke test) contre le fournisseur réel, marqué de manière à ne pas s’exécuter à chaque commit.
test("builds an authorize URL with PKCE", () => {
const url = new URL(buildAuthorizeUrl("state-1", "challenge-1"));
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
expect(url.searchParams.get("state")).toBe("state-1");
});
Le callback est l’endroit où se cachent les bugs, testez donc explicitement les scénarios d’échec : un état discordant, un code rejoué et un jeton expiré doivent chacun produire une erreur claire plutôt qu’une session partiellement authentifiée.
Déconnexion et authentification unique (SSO)
Se déconnecter de votre application n’est pas la même chose que de se déconnecter du fournisseur d’identité. Une déconnexion complète effectue trois actions : elle détruit la session locale, révoque le refresh token au niveau du serveur d’autorisation et, pour l’authentification unique (single sign-on), met fin à la session du fournisseur afin que la connexion suivante ne la réutilise pas silencieusement.
app.post("/logout", async (req, res) => {
if (req.session.tokens?.refresh_token) {
await revoke(req.session.tokens.refresh_token);
}
req.session.destroy(() => res.redirect("/"));
});
L’authentification unique découle du même mécanisme : une fois qu’un utilisateur possède une session chez le fournisseur, les clients suivants reçoivent un code sans avoir à saisir à nouveau leur mot de passe. C’est pratique, et c’est aussi pourquoi la déconnexion sur un ordinateur partagé est plus cruciale qu’on ne le pense.
Rotation des refresh tokens et détection de réutilisation
OAuth n’impose pas que les refresh tokens soient à usage unique, mais les implémentations les plus robustes utilisent la rotation. Chaque rafraîchissement renvoie un nouveau refresh token et invalide le précédent. Si un ancien token est présenté à nouveau, le serveur sait qu’il a été volé et révoque l’ensemble de la famille de tokens.
export async function refresh(grant: { refresh_token: string; client_id: string }) {
const stored = await db.refreshToken.findByHash(hash(grant.refresh_token));
if (!stored || stored.revoked) {
if (stored) await revokeFamily(stored.familyId);
throw new Error("invalid_grant");
}
await db.refreshToken.revoke(stored.id);
return issueTokens(stored.userId, stored.familyId);
}
La détection de réutilisation est l’atout majeur : un refresh token volé devient un signal d’alerte plutôt qu’une porte dérobée permanente. Associez cela à une expiration glissante pour qu’un utilisateur actif reste connecté, tandis qu’un token abandonné finisse par expirer.
Bonnes pratiques
- Utilisez l’Authorization Code avec PKCE pour chaque client orienté utilisateur, y compris les SPA et les applications mobiles.
- Utilisez les Client Credentials pour les communications machine-to-machine, avec une authentification par clé privée dans la mesure du possible.
- Enregistrez des redirect URIs exactes et rejetez tout ce qui ne correspond pas caractère par caractère.
- Envoyez et vérifiez toujours
state; ajoutez et vérifiez unnoncepour OIDC. - Demandez le minimum de scopes et considérez l’écran de consentement comme une étape significative.
- Gardez des access tokens à courte durée de vie, effectuez une rotation des refresh tokens et révoquez-les lors de la déconnexion.
- Vérifiez la signature de l’ID token,
iss,aud,expetnonceavant de lui faire confiance. - Conservez les client secrets et les clés privées dans un secret manager, jamais dans le frontend.
- Mettez l’introspection en cache brièvement et appuyez-vous sur des durées de vie courtes pour une révocation rapide.
Erreurs courantes
- Confondre OAuth avec l’authentification sans utiliser OpenID Connect.
- Déployer le flux implicite (implicit flow) simplement pour éviter une redirection.
- Stocker les access tokens dans le localStorage et les envoyer à n’importe quelle origine.
- Inclure le client secret dans une application mono-page (SPA) où n’importe qui peut le lire.
- Accepter n’importe quelle URI de redirection, ou une URI fournie via un paramètre de requête.
- Oublier de valider
statelors du callback. - Demander tous les scopes possibles sans jamais remettre la liste à jour.
- Supposer que la révocation d’un refresh token invalide instantanément les access tokens.
- Utiliser l’ID token comme bearer token pour votre API.
Et après ?
OAuth est la méthode permettant d’obtenir des jetons ; le guide sur les JWT explique comment ils sont construits et vérifiés. Si votre application est propriétaire (first-party) et que vous avez besoin d’une révocation immédiate, l’Auth par Session est un point de départ plus simple. Pour les clients machines n’impliquant jamais d’utilisateur, les Clés API présentent l’alternative pour des accès longue durée. Et comme chacun de ces flux transite par le réseau, un rappel sur le HTTP est recommandé.