How do I put roles and permissions in a JWT?
Short answer
Add them as claims when the token is issued, usually a roles array and a permissions array that the issuer signs with the rest of the token. Before you trust them, verify the signature, issuer, audience and expiry, then check permissions rather than role names. Keep lifetimes short, because a token shows the roles someone had when it was issued. loginos puts both claims in every ID token.
You want your app or API to know what someone may do without a database lookup on every request. A JWT (JSON Web Token, RFC 7519) can carry that: it is a JSON object signed by its issuer, and each field in it is a claim. Add a roles claim and a permissions claim before the token is signed, and every service that trusts the issuer can read them.
The harder part is everything around those claims: which ones to use, how big the token may grow, how to verify it, and what happens when someone’s roles change after it was issued.
Roles, permissions or scopes: what goes in the token?
Three kinds of authorization claims often get mixed up. They answer different questions:
| Claim | Example | Answers | Decided by |
|---|---|---|---|
roles | ["editor", "member"] | Which jobs the person has | Your admins |
permissions | ["posts:read", "posts:publish"] | What the person may do | The roles, expanded |
scope | "openid posts:read" | What this client app may do for the person | The consent the person gave the app |
A role is a named bundle of permissions, like editor. A permission is one action on one kind of resource, like posts:publish. A scope is OAuth 2.0’s limit on a client app (RFC 6749, section 3.3), not on the person. A reporting tool that was only granted posts:read can’t publish, even when an admin uses it.
Put both roles and permissions in the token, and check permissions in your code. A check for posts:publish keeps working when you add a publisher role or move the permission between roles. A check for editor has to change each time. Keep roles for coarse choices, like which dashboard to show.
roles, groups and entitlements are registered JWT claim names, from SCIM (RFC 7643) and RFC 9068. scope is one space-separated string (RFC 8693), not an array. permissions isn’t registered, but it is a common private name.
How do you add the claims, and should you namespace them?
If an identity provider issues your tokens, the provider adds the claims. Some do it by default, and others need a setting or a small hook that runs at sign-in. The provider comparison shows which is which. If your own server signs tokens, put the two arrays in the payload next to the standard claims, here with jose:
import { SignJWT } from "jose";
const token = await new SignJWT({ roles: user.roles, permissions: permissionsFor(user.roles) })
.setProtectedHeader({ alg: "RS256", kid: "2026-10" })
.setIssuer("https://auth.example.com")
.setAudience("https://api.example.com")
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime("10m")
.sign(privateKey);RFC 7519 sorts claim names into three kinds:
- Registered names like
iss,sub,aud,exp,nbf,iatandjtihave fixed meanings. Never reuse them for something else. - Public names are registered with IANA or collision-resistant, like a URL you control:
https://example.com/roles. - Private names, like a plain
roles, are fine when only your issuer and your apps read the token.
Namespace your claims when tokens pass through code you don’t control, or when your provider asks for it.
How big can a JWT get?
A JWT travels with every request, in the Authorization header or in a cookie, so the smallest limit along the way decides its size. RFC 6265 only asks browsers to support 4,096 bytes per cookie. Node.js accepts 16 KB of headers by default. nginx answers 400 Bad Request when one header doesn’t fit its buffer, 8 KB by default.
Base64url encoding makes the payload about a third bigger. Fifty permission keys of 20 characters each add about 1.2 KB of JSON, so about 1.6 KB of token. Microsoft Entra ID shows where large claims end up: a JWT carries at most 200 groups. Beyond that, the token gets an overage marker instead of the list, and the app has to ask Microsoft Graph.
To keep tokens small:
- Send roles only, and expand them to permissions on your server.
- Use coarse permissions like
posts:write, not one per button. - Keep rights on single objects out of the token. “Jane may edit document 42” belongs in your database.
Why do roles in a token go stale?
A JWT is a snapshot. The issuer signs it once, and the token stays valid until exp, whatever happens to the account. If you remove someone’s admin role at 10:05, the token they got at 10:00 still says admin until it expires. Four tools help, and most systems combine them:
- Short lifetimes. Access tokens of 5 to 15 minutes limit how long an old role lasts. The next token carries the current roles.
- Fetching current claims. The OpenID Connect userinfo endpoint (Core, section 5.3) returns the claims as they are now. Call it before sensitive actions, or every few minutes.
- Asking about the token. Token introspection (RFC 7662) tells an API whether a token is still
active, at the cost of a request per check. - Revoking or blocking. Revocation (RFC 7009) invalidates a token at the issuer, and blocking an account stops new sign-ins. A JWT that your API checks offline notices neither, so pair them with short lifetimes.
Web apps that copy roles into their own session after sign-in have the same problem, on a longer clock. Re-read the roles on a timer and before anything sensitive.
How do you check permissions safely?
Never trust roles that come from the client. A role in a form field, a request header, localStorage or a token decoded in the browser can be changed by whoever controls the browser. Hiding a button is fine, but the server decides, on every request.
Before you read any claim, check these, following RFC 8725 and OpenID Connect Core (section 3.1.3.7):
- The signature, with the issuer’s public key from its JWKS (JSON Web Key Set), matched by
kid. Allow only the algorithm you expect, likeRS256, and nevernone. issequals your issuer exactly.audcontains your client ID or API identifier. A token issued for another app isn’t for you, even when the same issuer signed it.expis in the future, with a minute of leeway at most.- For ID tokens,
noncematches the one you sent.
Then deny by default, and allow only what the verified token grants. Answer 401 when verification fails and 403 when the permission is missing:
import { createRemoteJWKSet, jwtVerify } from "jose";
const ISSUER = "https://auth.example.com";
const AUDIENCE = "https://api.example.com";
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));
type Claims = { sub: string; roles?: string[]; permissions?: string[] };
async function verify(token: string): Promise<Claims> {
const { payload } = await jwtVerify<Claims>(token, JWKS, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["RS256"],
});
return payload;
}
const can = (claims: Claims, permission: string) => claims.permissions?.includes(permission) ?? false;
export async function POST(req: Request) {
const token = req.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
const claims = await verify(token).catch(() => null);
if (!claims) return new Response("Unauthorized", { status: 401 });
if (!can(claims, "posts:publish")) return new Response("Forbidden", { status: 403 });
// …publish the post
return new Response(null, { status: 204 });
}How loginos does it
loginos is an OpenID Connect provider, and roles are part of every platform. In the console’s Roles tab you define permissions with keys like posts:publish, roles like editor that group them, and default roles that everyone who joins gets.
Jane’s roles
Tap to try your own mix.
{ "sub": "9b1f6c2e-…", "preferred_username": "jane", "roles": [ "member" ], "permissions": [ "comments:write", "posts:read" ]}The ID token, the user object from the token endpoint and /oauth/userinfo always carry roles and permissions, whatever scopes you asked for, with no hook to write. When your server exchanges the code, use user directly. If an ID token reaches you any other way, verify it with the code above: the issuer is https://loginos.io, the keys are at https://loginos.io/.well-known/jwks.json, and the audience is your app key. The claims reference lists the rest of the token.
Tokens carry the roles someone had when they signed in, and loginos has no refresh tokens. Your app keeps its own session and re-reads roles when it needs them: from /oauth/userinfo while the access token is valid (one hour), or from the Management API at any time.
const basic = Buffer.from(`${process.env.LOGINOS_APP_KEY}:${process.env.LOGINOS_APP_SECRET}`).toString("base64");
type SessionUser = { sub: string; roles: string[]; permissions: string[] };
// Every few minutes, or before a sensitive action. Server only: it uses your app secret.
export async function refreshRoles(user: SessionUser): Promise<boolean> {
const res = await fetch(`https://loginos.io/api/v1/users/${user.sub}`, {
headers: { authorization: `Basic ${basic}` },
});
if (!res.ok) throw new Error(`loginos answered ${res.status}`);
const member = await res.json(); // { id, roles, permissions, blocked, … }
if (member.blocked) return false; // end your own session
user.roles = member.roles;
user.permissions = member.permissions;
return true;
}The Management API allows 600 requests a minute per platform, so cache the answer per session.
You change roles in the console’s Users tab or with PUT /api/v1/users/{sub}/roles, and the new roles appear in tokens from the next sign-in. To shut someone out, block them: POST /api/v1/users/{sub}/block stops sign-in on all your domains, and their existing tokens stop working at once. The roles guide has the details.
loginos roles belong to the platform and apply on all of its domains. They aren’t per tenant: if your customers need their own admins inside your product, keep that membership in your database, as in managing roles across apps.
Frequently asked questions
Should roles go in the ID token or the access token?
Put them where the check happens. An ID token tells your app who signed in, and its aud is your client ID. An access token is meant for an API, and its aud names that API. An API should check access tokens and reject an ID token issued to a different client.
Can a user change the roles in their own JWT?
They can read and edit the payload, because it is only base64url-encoded. They can’t make a valid signature for the edited payload without the issuer’s private key, so a server that verifies every token rejects it. A server that only decodes the token, without verifying it, accepts the forgery.
How do I remove a role right away?
Change the role at the source, then make sure your apps notice. Short-lived tokens pick up the change at the next refresh, and a userinfo or management API request picks it up at once. To lock someone out completely, block the account at your provider and end your app’s own session.
Should I encrypt a JWT that carries roles?
Usually not. Role and permission names are rarely secret, and the signature already stops tampering. If the payload must stay hidden from the person holding the token, use an encrypted JWT (JWE, RFC 7516), or keep the data on your server and put only an ID in the token.
One account for every domain you run
loginos is an OpenID Connect provider with passkeys, a user pool shared by all of your domains, roles in every token, and account linking for the users you already have. Add your platform and its domains in a few minutes.
Keep reading
- Roles and permissionsHow do I manage user roles across multiple apps?Keep roles in one central place, usually your identity provider, and have every app read them from there instead of keeping its own copy. Name permissions per app, like billing:invoices:refund, deliver them in tokens at sign-in, re-check them through an API, and offboard people by blocking their account once. In loginos, one platform’s roles apply on all of its domains.7 min read
- Roles and permissionsWhich auth providers support roles and permissions out of the box?Auth0, Clerk, WorkOS, Kinde and Logto have both roles and permissions built in, and Keycloak, Zitadel and FusionAuth focus on roles. Amazon Cognito only has groups, while Firebase Auth and Supabase leave roles to custom claims you set yourself. The providers differ most in whether roles reach the token by default and whether they are per organization. loginos puts roles and permissions in every token.9 min read
- Sign-in across domainsHow do I share a login session across multiple domains?You can’t share a session cookie between two different domains, because browsers only send a cookie to the domain that set it. Instead, each domain keeps its own session and gets it from one central login, an OpenID Connect provider. Someone who is already signed in there is sent straight back with a one-time code, without seeing a form.6 min read
- Sign-in across domainsHow do I implement single sign-on across different domains?Use one OpenID Connect provider for all of your domains, and register each domain’s callback URL with it. On every domain, add a route that redirects to the provider with state and PKCE, and a callback that checks state, exchanges the code on your server and starts that domain’s own session. People signed in on one domain then pass through the others without a form.8 min read