Map roles to scopes via Hydra introspection
Translate user roles to API permissions
Scopes describe what a token can do; roles describe what a user is. The two are related but distinct. This recipe wires them together.
The pattern
- User has
role: "admin"in their Kratos identity traits. - When they get an OAuth2 token, the ID token includes
role: "admin"(since Hydra includes traits). - Your backend, on every API call, reads
rolefrom the token and computes which actions are permitted.
So roles → scopes is computed in your backend, not in OAuth2. Token introspection doesn't change scopes based on roles.
Why not encode roles as scopes
You could try:
role: "admin"→ token hasadmin:read admin:writescopes.
Problems:
- A token's scope set is fixed at issuance. If the user's role changes, their existing tokens still have the old scopes.
- More moving parts: now scopes are derived from roles in some opaque mapper.
- Scope enforcement happens in your APIs anyway; the scope→role mapping is just indirection.
Cleaner: read role from the token claims directly in your backend, treat it as the authorization signal.
Code
async function authorize(req: Request, action: string): Promise<{user: string; role: string}> {
const auth = req.headers.get("authorization");
if (!auth?.startsWith("Bearer ")) throw new HTTPError(401, "missing token");
const info = await introspect(auth.slice(7));
if (!info.active) throw new HTTPError(401, "inactive");
const role = info.ext?.role ?? "user"; // role from claims
const sub = info.sub;
if (!isPermitted(role, action)) {
throw new HTTPError(403, "insufficient_role");
}
return { user: sub, role };
}
const ROLE_PERMISSIONS: Record<string, string[]> = {
admin: ["read", "write", "delete", "manage_users"],
operator: ["read", "write"],
user: ["read"],
};
function isPermitted(role: string, action: string): boolean {
return ROLE_PERMISSIONS[role]?.includes(action) ?? false;
}Where role is in the token
If you've configured Hydra to include identity traits in the ID token (default in Olympus), role appears in:
- ID token (
iat,exp,sub, ...,role). - Userinfo response.
- Introspection response (
ext.roletypically; depends on Hydra's claim mapping).
Check via:
ID_TOKEN=...
echo "$ID_TOKEN" | cut -d. -f2 | base64 -d | jqYou should see "role": "admin".
Group-based authorization
Same pattern with arrays:
const groups: string[] = info.ext?.groups ?? [];
if (!groups.includes("engineers")) throw new HTTPError(403);What if the role isn't in the token
Reasons it might not be:
- The user's identity doesn't have a
roletrait. - Hydra's claim mapper isn't configured to include
role. - Your client requests scopes that exclude profile/traits.
For (1): ensure your IAM/CIAM identity schema declares role and your seed includes default roles.
For (2): see Cookbook, Add custom claim.
For (3): include profile or your custom scope when requesting tokens.
Token revocation when role changes
Changing a user's role doesn't automatically revoke their tokens. Their old tokens keep their old role until they expire.
For immediate effect:
- Update the role in Kratos.
- Revoke all of that user's Hydra sessions:
DELETE /admin/oauth2/auth/sessions/login?subject=<sub>. - User must re-authenticate; the new token has the updated role.