<!-- llms.txt: https://workos.com/llms.txt -->

# Access token claims

## Introduction

A Connect access token is a JWT that authorizes an application to call your API or MCP server. Use its claims to identify the caller, check the client's scopes, and resolve user permissions when access depends on the user's role.

For tokens that authenticate users in your own application, see [Session tokens](https://workos.com/docs/authkit/sessions/integrating-sessions/access-token).

## Claims

Connect issues user tokens to [OAuth applications](https://workos.com/docs/authkit/connect/oauth) and MCP clients, including those registered through Dynamic Client Registration (DCR) or Client ID Metadata Document (CIMD). User tokens are issued through the authorization code, refresh token, and device code grants. [M2M applications](https://workos.com/docs/authkit/connect/m2m) use the client credentials grant to obtain tokens without a user.

| Claim | User token | M2M token |
| --- | --- | --- |
| `iss` | AuthKit domain | AuthKit domain |
| `aud` | Resource or environment client ID | Environment client ID |
| `sub` | User ID | Application client ID |
| `client_id` | Requesting application client ID | Application client ID |
| `org_id` | Organization selected at authorization | Application's organization |
| `sid` | Consent ID | Not included |
| `scope` | Granted scopes | Granted scopes |
| `jti` | Token ID | Token ID |
| `exp` | Expiration time | Expiration time |
| `iat` | Issue time | Issue time |

The issuer is the HTTPS URL of your AuthKit domain. For user tokens, the audience is the requested [resource indicator](https://workos.com/docs/authkit/mcp), or the environment client ID when no resource is configured. The M2M audience is not configurable per application. Granted scopes are a space-delimited string.

User tokens can include additional claims from your environment's [JWT template](#add-roles-with-a-jwt-template). M2M tokens do not support JWT templates or custom claims.

## Authorize requests

Check the client's scopes to determine what the calling application is allowed to do. Scopes do not enforce the user's role-based permissions, so a scoped request also needs a user-permission check when access varies by user.

Connect tokens do not include a permissions claim. For user-specific authorization, choose one of these approaches:

- [Add roles with a JWT template](#add-roles-with-a-jwt-template) if your server maintains the role-to-permission mapping.
- [Check permissions server-side](#check-permissions-server-side) if WorkOS maintains the mapping. Use this approach if you are unsure which to choose.

### Configure client scopes

For an OAuth application created in the WorkOS dashboard, assign permissions from your environment to the application as scopes. Requesting an unassigned scope fails at the authorization endpoint with `invalid_scope`; assign that scope or remove it from the request.

Clients registered through Dynamic Client Registration or Client ID Metadata Document cannot have scopes assigned per client. Updating their scopes returns HTTP 422 with `Cannot update scopes for dynamically registered applications`. These clients receive the standard OpenID Connect scopes: `openid`, `profile`, `email`, and `offline_access`.

For additional permission scopes on dynamically registered clients, [contact support](mailto:support@workos.com?subject=Default%20scopes%20for%20dynamically%20registered%20clients) about an environment-wide default set. These scopes still do not restrict access based on the user's role.

### Add roles with a JWT template

Add `organization_membership.roles` to your environment's JWT template to put the user's role slugs on every Connect user token:

```js title="Template"
{
  "roles": {{ organization_membership.roles }}
}
```

```json title="Output"
{
  "roles": ["admin", "billing"]
}
```

Permissions are not in the template context. A template that references `organization_membership.permissions` fails validation with `Invalid path`. One template applies to the whole environment, so these claims also land on your AuthKit session tokens, which are limited to 3072 bytes of rendered template output.

### Check permissions server-side

After verifying the token, use `sub` and `org_id` to resolve the user's effective permissions from the WorkOS API, then gate the request on the result. Cache the result per user and organization so each request doesn't call the API; a role change is reflected when the cache entry expires.

1. Verify the JWT against `https://authkit_domain/oauth2/jwks` and check `iss` and `aud`.
2. Call [List organization memberships](https://workos.com/docs/reference/authkit/organization-membership/list) with `user_id` set to `sub` and `organization_id` set to `org_id`. The membership carries `role.slug`, and `roles[]` when multiple roles are enabled.
3. For each role slug, call [Get a custom role](https://workos.com/docs/reference/roles/custom-role/get) with `org_id` and the slug. It returns the role's `permissions` for both environment roles and organization custom roles.
4. Take the union of the permission slugs and reject the request with `403` if the required permission is missing.

:::code-group{title="Authorize an MCP request on WorkOS permissions"}

```js language="js"
import { jwtVerify, createRemoteJWKSet } from 'jose';
import { WorkOS } from '@workos-inc/node';

const workos = new WorkOS(process.env.WORKOS_API_KEY);
const JWKS = createRemoteJWKSet(new URL('https://authkit_domain/oauth2/jwks'));

// Permissions are cached per user and organization so each request
// doesn't call the WorkOS API. Role changes apply after the entry expires.
const permissionCache = new Map();
const CACHE_TTL_MS = 60_000;

async function permissionsFor(userId, organizationId) {
  const key = `${userId}:${organizationId}`;
  const cached = permissionCache.get(key);
  if (cached && cached.expiresAt > Date.now()) {
    return cached.permissions;
  }

  const { data: memberships } =
    await workos.userManagement.listOrganizationMemberships({
      userId,
      organizationId,
    });

  const membership = memberships[0];
  if (!membership) {
    return new Set();
  }

  // `roles` is populated when multiple roles are enabled; `role` otherwise.
  const roleSlugs = membership.roles?.map((role) => role.slug) ?? [
    membership.role.slug,
  ];

  const roles = await Promise.all(
    roleSlugs.map((slug) =>
      workos.authorization.getOrganizationRole(organizationId, slug),
    ),
  );

  const permissions = new Set(roles.flatMap((role) => role.permissions));
  permissionCache.set(key, {
    permissions,
    expiresAt: Date.now() + CACHE_TTL_MS,
  });

  return permissions;
}

const requirePermission = (permission) => async (req, res, next) => {
  const token = req.headers.authorization?.match(/^Bearer (.+)$/)?.[1];

  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }

  let payload;
  try {
    ({ payload } = await jwtVerify(token, JWKS, {
      issuer: 'https://authkit_domain',
      audience: 'https://mcp.example.com',
    }));
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' });
  }

  // Connect tokens carry no `permissions` claim. Resolve them from
  // the user's organization membership instead.
  if (!payload.org_id) {
    return res.status(403).json({ error: 'No organization selected' });
  }

  const permissions = await permissionsFor(payload.sub, payload.org_id);

  if (!permissions.has(permission)) {
    return res.status(403).json({ error: `Missing permission: ${permission}` });
  }

  req.userId = payload.sub;
  req.organizationId = payload.org_id;
  next();
};

// Example protected MCP endpoint
app.post('/mcp', requirePermission('documents:read'), (req, res) => {
  res.json({ data: 'Protected resource', userId: req.userId });
});
```

:::

A token without `org_id` belongs to a user who has no organization membership, so there are no roles to resolve. Reject it, or treat it as having no permissions.

## Identify the organization in M2M requests

To map the calling organization to an identifier in your system, read `org_id` from the token and call [Get an organization](https://workos.com/docs/reference/organization/get) once to read `external_id` or `metadata`, then persist the mapping. Refresh the cached mapping when the organization's identifiers change.
