loginos

How do I put roles and permissions in a JWT?

loginos teamUpdated 8 min read

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:

ClaimExampleAnswersDecided by
roles["editor", "member"]Which jobs the person hasYour admins
permissions["posts:read", "posts:publish"]What the person may doThe roles, expanded
scope"openid posts:read"What this client app may do for the personThe 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:

Issue a token
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, iat and jti have 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:

  1. Short lifetimes. Access tokens of 5 to 15 minutes limit how long an old role lasts. The next token carries the current roles.
  2. 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.
  3. 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.
  4. 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):

  1. The signature, with the issuer’s public key from its JWKS (JSON Web Key Set), matched by kid. Allow only the algorithm you expect, like RS256, and never none.
  2. iss equals your issuer exactly.
  3. aud contains your client ID or API identifier. A token issued for another app isn’t for you, even when the same issuer signed it.
  4. exp is in the future, with a minute of leeway at most.
  5. For ID tokens, nonce matches 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"  ]}
1/3Everyone who joins gets the default role, member.

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.

Re-read roles
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

All answers