By AndyPublished

JWT Scopes vs Roles: Scope, scp, roles and permissions Claims for Authorisation

Scopes and roles both end up as claims in a JWT, but they answer different questions. A scope limits what a client application may do on a user's behalf, and is usually granted by the user's consent: a calendar app may read your calendar but not delete it. A role describes what the user is allowed to do in the system, assigned by an administrator: an admin may delete anything. In a token, scopes normally appear as a space-delimited scope string (RFC 8693 §4.2, used by RFC 9068 for access tokens), though some providers use scp. Roles have no single standard claim. An API that handles delegated access should check both: the client must hold the scope, and the user must hold the role.

JWT Scopes vs Roles: The Difference

ScopesRoles
AnswersWhat has this client been allowed to do on the user's behalf?What is this user (or app) allowed to do in this system?
Granted byThe user's consent or the client's registrationAn administrator assigning roles or groups
Applies toThe client application, for this tokenThe subject, across every client they use
Standard claimscope (RFC 8693 §4.2, used by RFC 9068)None universal; RFC 9068 suggests roles / groups / entitlements
Typical valuesread:orders, orders.write, api://app/accessadmin, support-agent, billing-manager

The two combine as an intersection. If a user with the admin role signs in to a read-only reporting tool that was only granted orders:read, the token should not let that tool delete orders, even though the user could. Conversely, a client grantedorders:write cannot write orders for a user whose role does not allow it. Scopes stop over-privileged clients; roles stop over-privileged users.

In a client credentials flow there is no user, so the "role" question collapses into the client's own permissions. Some providers express those as scopes; Microsoft Entra ID puts them in roles.

JWT Scope Claim and Its Format

OAuth 2.0 defines scope in requests and responses as a list of space-delimited, case-sensitive strings (RFC 6749 §3.3). RFC 8693 §4.2 registered scope as a JWT claim with the same format: a single JSON string, space-separated, not an array. RFC 9068, the profile for JWT access tokens, says tokens issued in response to a request with a scope parameter should include this claim.

JWT scope claim example

{
  "iss": "https://login.example.com/",
  "sub": "user_8412",
  "aud": "https://api.example.com",
  "client_id": "reporting-dashboard",
  "scope": "openid orders:read invoices:read",
  "exp": 1790284151,
  "iat": 1790283251,
  "jti": "5c3a1f0e-6b1d-4e0a-9f3a-2d1b7c8e9a10"
}

Two formats you will meet in the wild: Microsoft Entra ID uses scp as a space-separated string in user tokens, and Okta uses scp as a JSON array. Code that reads scopes should normalise, rather than assume:

function getScopes(payload) {
  const raw = payload.scope ?? payload.scp ?? [];
  return new Set(Array.isArray(raw) ? raw : String(raw).split(' ').filter(Boolean));
}

function requireScope(payload, needed) {
  if (!getScopes(payload).has(needed)) {
    // RFC 6750 §3.1: respond 403 with error="insufficient_scope"
    throw Object.assign(new Error('insufficient_scope'), { status: 403 });
  }
}

Never check scopes with a substring match. payload.scope.includes('orders') is true fororders:read and for no-orders; split and compare whole values.

JWT Roles Claim

There is no registered roles claim in RFC 7519. RFC 9068 §2.2.3.1 recommends that an authorisation server wanting to include roles, groups or entitlements use the roles,groups and entitlements attribute names from the SCIM core schema (RFC 7643). In practice each provider has its own shape:

ProviderScope claimRole / permission claim
RFC 9068 / generic OAuthscope: space-delimited stringroles, groups or entitlements (optional)
Microsoft Entra IDscp: space-delimited string (user tokens)roles: array (app roles; also app permissions in client credentials tokens)
Oktascp: arrayCustom claim such as groups, configured by you
Auth0scope: space-delimited stringpermissions: array (with RBAC and "Add Permissions in the Access Token")
Keycloakscope: space-delimited stringrealm_access.roles and resource_access.<client>.roles
AWS Cognitoscope: space-delimited string (access token)cognito:groups: array
// Keycloak access token (trimmed)
{
  "azp": "orders-ui",
  "scope": "openid profile email",
  "realm_access":    { "roles": ["offline_access", "support-agent"] },
  "resource_access": { "orders-api": { "roles": ["refund"] } }
}

// Microsoft Entra ID v2.0 access token (trimmed)
{
  "aud": "6e74172b-be56-4843-9ff4-e66a39bb12e3",
  "scp": "Orders.Read Orders.Write",
  "roles": ["Orders.Admin"]
}

Custom role claims are private claims. If you add your own, avoid collisions by namespacing them, for examplehttps://example.com/roles; Auth0's documentation recommends namespaced names for custom claims. The claims guide explains registered, public and private claims.

JWT Roles and Permissions

A role is a named bundle (support-agent); a permission is a single action (orders:refund). You can put either in the token. Auth0's RBAC feature, for example, can emit a permissions array: enable RBAC on the API and turn on "Add Permissions in the Access Token", and the token lists every permission granted to the user through their roles. Auth0's own Express sample checks it like this:

import { auth, claimIncludes, requiredScopes } from 'express-oauth2-jwt-bearer';

app.use(auth({ issuerBaseURL: 'https://YOUR_TENANT.auth0.com', audience: 'https://api.example.com' }));

// Delegated check against the space-delimited "scope" claim
app.get('/orders', requiredScopes('read:orders'), listOrders);

// User permission check against the "permissions" array
app.post('/orders/:id/refund', claimIncludes('permissions', 'refund:orders'), refundOrder);

Putting permissions in the token makes the API simpler (no lookup) but makes the token larger and freezes the permissions until it expires. Putting roles in the token and mapping them to permissions in the API keeps the token small and lets you change what a role can do with a deploy. Either works; mapping in the API is easier to evolve.

Scopes and Roles in Machine-to-Machine Tokens

Service-to-service calls using the client credentials grant (RFC 6749 §4.4) have no user, so there is no consent step and no user role. Whatever the token carries describes the calling service itself, and thesub is typically the client ID. Providers differ in where they put those permissions. Auth0, Okta, Keycloak and Cognito express them as scopes granted to the client, so your API sees ascope (or scp) claim just as it would for a user token. Microsoft Entra ID instead puts application permissions in the roles claim and omitsscp, which is why an Entra-protected API often needs two checks: scp for delegated calls and roles for app-only calls.

Decide up front whether an endpoint should be reachable by services at all. A policy that only readsscope may accidentally grant a background job the same power as a signed-in administrator, or refuse it entirely. Naming machine-only permissions distinctly (for exampleorders:sync) makes the intent visible in both the token and the code, and the microservices guide covers how to propagate identity across service hops.

JWT Claims vs Scopes

"Claims" is the general term: every name/value pair in the payload is a claim, including scope. The confusion comes from OpenID Connect, where requesting a scope such as profile oremail causes a set of claims (name, email,email_verified) to be returned. In that sense a scope is a request, and claims are what you receive. In an access token, the scope claim then records which scopes were granted.

JWT Scope vs Audience

aud says which API the token is for; scope says what the token allows at that API. Check audience first. A token with scope: "orders:write" issued for a different API must be rejected outright, not authorised against your scope names, because scope strings mean nothing outside the audience they were issued for. RFC 9068 makes the same point: scopes in the token are meaningful only for the resources in aud. The audience claim guide covers validation in detail.

JWT Scope Naming Conventions

RFC 6749 only restricts scope tokens to printable ASCII without spaces, double quotes or backslashes. The rest is convention. Common styles:

  • ·action:resource, e.g. read:orders. Used in Auth0 examples.
  • ·resource:action, e.g. orders:read. Groups naturally when sorted.
  • ·Resource.Action, e.g. Orders.Read, User.Read. The Microsoft Graph style.
  • ·URL scopes, e.g. https://www.googleapis.com/auth/calendar.readonly. Globally unique, verbose.

Pick one style and keep it. Make scopes coarse enough that a consent screen makes sense to a human (orders:read, not orders:read:line-items:v2), and avoid scopes that encode a specific user or record, which belong in claims or in your database instead.

JWT Role-Based Authorisation: Design Guidance

  • ·Verify first, authorise second. Only read scopes and roles from a token whose signature, iss, aud and exp have been validated. A decoded but unverified token is just user input; see decoder vs validator.
  • ·Return the right status. Invalid or expired token: 401. Valid token lacking a scope or role: 403, with error="insufficient_scope" in WWW-Authenticate for OAuth APIs (RFC 6750 §3.1).
  • ·Keep tokens small. Dozens of roles or hundreds of group IDs push tokens past header limits. Entra ID already replaces the groups claim with an overage pointer above 200 groups in a JWT. See JWT size limits.
  • ·Remember staleness. Roles in a token are a snapshot from issue time. Revoking a role takes effect only when the token expires, so keep access token lifetimes short or check critical permissions server-side.
  • ·Do not trust the client for roles. The client never decides roles; they come from the issuer and are protected by the signature.
  • ·Object-level checks still live in your code. A role says "support agents may refund orders", not "this agent may refund this order". Tenancy and ownership checks cannot be expressed in a token.
ℹ
When a request gets a 403 and you are not sure why, decode the access token in the jwtdecode.app decoder (it runs locally in your browser) and compare the actual scope, scp or role claim with the name your policy reads. A renamed or missing claim is the usual answer. The JWT debugging routine covers the rest.

Summary

Scopes limit what a client may do on a user's behalf; roles and permissions describe what the user may do. The standard scope claim is a space-delimited scope string (RFC 8693 §4.2, RFC 9068), withscp used by Entra ID (string) and Okta (array). Roles have no universal claim: Entra ID uses roles, Keycloak realm_access andresource_access, Auth0 a permissions array, Cognitocognito:groups. Validate the token and its audience first, normalise scope formats, compare whole values, return 403 for missing permissions, and keep fine-grained and object-level decisions in your API.

Ready to decode a token?
Use the free JWT decoder — paste any token for instant results, entirely in your browser.
Open JWT Decoder