Scopes and claims

What a token may contain is decided by four things: the audiences (APIs) it is for, the scopes those audiences declare, the roles of the user, and the scopes the client is allowed to ask for. This page explains each, how a request is validated, when users are asked for consent, and which claims end up in ID tokens, access tokens and userinfo. The protocol side is in OpenID Connect and OAuth.

Audiences

An audience is an API (resource server) that accepts pykrete access tokens. It has:

Field Meaning

Audience ID

A + 32 letters and digits, generated. Used in requests and in the aud claim

Identifier

Unique free-form name, for example https://api.example.com

Name, description, picture

Shown on the consent screen

Access token signature

RS256 (default) or ES256, see Signing and keys

Owner

A user, an organization, or the system

Scopes

Declared by the audience, see below

Audiences are created under Account → OIDC → Audiences (/account/audiences), owned by yourself or by an organization where you have org:audiences, or by admins through GraphQL. See Self-service and Admin access.

Each audience declares its scopes: an id without spaces (for example read:projects), a name and a description. Name and description are what users see on the consent screen.

Requesting an audience

Pass the Audience ID as audience to /authorize (or /device-auth) together with the scopes you need:

https://auth.infra.delta.rocks/authorize?response_type=code
  &client_id=C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
  &audience=A9xK3mP7qR2sT6vW0yZ4bC8dF1gH5jL3n
  &scope=openid%20email%20read:projects
  &state=...&code_challenge=...&code_challenge_method=S256
  • One audience per request. More audiences are added automatically by the client's required scopes (see Client allowed and required scopes), so a token can cover several APIs.

  • The resulting access token has "aud": "A9xK3mP7qR2sT6vW0yZ4bC8dF1gH5jL3n" (an array when there are several, [] when there is none). APIs check that aud contains their Audience ID.

  • An unknown audience fails the request with invalid_client.

  • All audiences in one token must use the same access token signature kind, otherwise the token request fails with invalid_target.

The self audience

pykrete itself is an audience, with Audience ID A____________________________SELF and the issuer (https://auth.infra.delta.rocks/) as Identifier. Its scopes:

Scope Meaning

account

"View and manage your account": the token can call /graphql as the user (me, manage) and register clients owned by the user (POST /register). Every user holds it; a client still needs it allowed or required. Intended for first-party apps; the account dashboard requires it.

admin:graphql

Full admin GraphQL access. Held through the role:admin role. See Admin access.

org:manage, org:members, org:roles, org:clients, org:audiences, org:access, org:api-keys

Organization permissions. They are granted by organization roles and checked by the management API from the user's roles; they are not meant to be requested by apps. See Self-service.

Only admins can allow a client to use self audience scopes. Tokens whose only audience is the self audience are signed with RS256.

Builtin scopes

Builtin scopes need no audience, no role and no client allowance: any client may request them for any user. They still need consent.

Scope Consent label Effect

openid

User id

An ID token is issued; /userinfo works

profile

Profile

name, picture, updated_at (each only if the user has it)

email

Email

email and email_verified: true, only if the user has a verified address

offline_access

Offline access

A refresh token is issued, see Refresh tokens

organizations

Organizations

orgs claim with the organizations the user shares with the app, see Organizations

created_at

Registration time

Not available yet

App key scopes

app_key:<id> requests an end-to-end encryption key for the app. The client must be allowed that app key, the user always sees a consent prompt for it (it is never remembered), and the key is delivered in keys_jwe of the token response. See App keys.

Roles

A role is a named bundle of scopes, each scope being an (audience, scope) pair. Users get audience scopes only through roles (account aside).

Global roles

Granted to a user directly (admin GraphQL: grantRole / revokeRole on a user). Always count.

Organization roles

Held as a member of an organization. They count only while that organization is the active organization of the request (see Organizations). Unless an admin sets it up, a role owned by an organization may only contain scopes of that organization's audiences plus org: permissions.

Organization-assignable global roles

Global roles that organization admins may assign to members, flagged by an admin.

Built-in roles:

Role Scopes

role:admin

admin:graphql on the self audience

role:org-admin

All org: permissions; organization-assignable

role:org-member

None; organization-assignable

The scopes a user may receive for audience A are therefore:

  • all scopes of A in the user's global roles, plus

  • all scopes of A in the user's member roles in the active organization, if any, plus

  • account, if A is the self audience.

Example: audience A9x... declares read:projects and write:projects. Role role:projects-reader contains (A9x..., read:projects). A user who holds that role can get read:projects; requesting write:projects fails for them (see Scope validation).

Client allowed and required scopes

Each client lists audience scopes it may use:

Allowed scopes

The client may request them. They are included only when requested.

Required scopes

Always added to every user token of the client, together with their audience, even when not requested and without an audience parameter. They count as allowed.

Both are edited on the client's Scopes tab or with the setAllowedScopes / setRequiredScopes mutations. You can only add scopes of audiences you can manage (your own, your organization's with org:audiences, or any as admin).

Uses for required scopes:

  • Give a client an API token without passing audience (the account dashboard requires account this way).

  • Restrict who can use an app: if the client requires (A..., use:app), everyone without a role carrying that scope gets invalid_scope at sign-in.

  • Give device flow tokens access to an API (device tokens carry only required scopes, see Device authorization grant).

Builtin scopes cannot be allowed, required or restricted per client.

Scope validation

Validation is strict: pykrete never silently drops a scope. If anything is wrong, the whole request fails with invalid_scope, listing every problem. The rules:

  1. Split scope on spaces and add the client's required scopes (and their audiences).

  2. Builtin scopes always pass.

  3. app_key:<id> passes if the client is allowed that app key.

  4. Every other scope must be declared by at least one of the token's audiences; otherwise it is undefined. A scope of an audience you did not request (and that no required scope brings in) is undefined too.

  5. For every token audience that declares the scope, the user must hold it (see Roles) and the client must be allowed it.

Validation runs at /authorize (after sign-in and the organization step), again at the token endpoint, and on every refresh, so losing a role makes the next refresh fail.

Error descriptions look like this (the undefined scope value, here read:all, is obfuscated because a user could have typed it):

invalid scope: undefined scope: <E>E9JP2S1TC5P6R</E>
invalid scope: user has no permission to use scope write:projects at AudienceId("A9xK3mP7qR2sT6vW0yZ4bC8dF1gH5jL3n")
invalid scope: client has no permission to use scope write:projects at AudienceId("A9xK3mP7qR2sT6vW0yZ4bC8dF1gH5jL3n")

At /authorize the error goes to the redirect URI (error=invalid_scope); at the token endpoint it is a 400 JSON error. If your app has optional features behind scopes, request them in a separate authorization request once the user enables the feature, rather than always asking for everything.

After sign-in and the organization step, pykrete compares the validated scopes with what the user already approved for this client:

  • audience scopes, remembered per user, client, audience and scope;

  • builtin scopes, remembered per user, client and scope;

  • app key scopes, never remembered.

If anything is new, the consent screen lists the new permissions grouped as "Your identity", "Encryption keys" and one group per API, plus the previously approved ones. The client name is shown on top, followed by a heading that names the targets and the active organization ("Requests access to your identity and Projects API in Acme"). Clients that are not first-party show their publisher (owner) under the name and whether they are verified by an admin.

  • Approve: the new permissions are remembered and the flow continues.

  • Cancel: the app gets error=access_denied.

  • prompt=consent: the screen is shown even when nothing is new.

  • prompt=none: if the screen would be needed, the app gets error=consent_required.

Required scopes are consented like any other scope. Remembered consent does not expire; withdrawing it is not available yet. Access → Applications (/account/applications) lists it, see Account dashboard.

Claims

ID token

Claim When

iss

Always, https://auth.infra.delta.rocks/ (trailing slash)

sub

Always, see Subject identifiers

aud

Always, the client id (a string)

exp, iat

Always; valid for 1 hour

nonce

If sent to /authorize (not on refresh)

name, picture, updated_at

profile, if set for the user. picture is an absolute https://auth.infra.delta.rocks/images/<hash> URL

email, email_verified

email, if the user has a verified address; email_verified is always true

orgs

organizations: array of {"id", "slug", "name", "roles"}

org_id, org_slug, org_name, org_roles

An organization is active

custom

Returned by the claims hook for id_token

Example with an active organization and the organizations scope:

{
  "iss": "https://auth.infra.delta.rocks/",
  "sub": "S8kM2pR6tV0xZ4bD8fH2jL6nP0rT4vX8z",
  "aud": "C7cJ2kQ9xVb4LmN8pR3sT6wY1zA5dF0gH",
  "exp": 1793530800,
  "iat": 1793527200,
  "orgs": [
    {"id": "O2bD6fH0jL4nP8rT2vX6zB0dF4hJ8lN2p", "slug": "acme", "name": "Acme", "roles": ["role:org-admin"]}
  ],
  "org_id": "O2bD6fH0jL4nP8rT2vX6zB0dF4hJ8lN2p",
  "org_slug": "acme",
  "org_name": "Acme",
  "org_roles": ["role:org-admin"]
}

Access token

Claim Value

iss, sub, exp, iat

As in the ID token

aud

Audience IDs: a string for one, an array for several, [] for none

azp

The client id

scope

Space-separated granted scopes, builtin and app key scopes included

org_id, org_slug, org_name, org_roles

An organization is active

custom

Returned by the claims hook for access_token

Profile, email and orgs claims are not put into access tokens. An API that needs them can call /userinfo with the token, or get them from the claims hook.

Userinfo

GET /userinfo returns sub plus the ID token claims other than iss, aud, exp, iat and nonce, as recorded when the access token was issued. It needs openid in the token.

Custom claims

With hooks configured, pykrete calls the claims hook on every token issuance (all grants, refreshes included) with the grant type, client id, audience identifiers, scope, the user (raw U... id plus public and private metadata), the active organization and the listed organization ids. The hook answers with extra claims for id_token and access_token:

{
  "id_token": {"groups": ["staff"]},
  "access_token": {"tenant": "acme"}
}

Hook claims cannot replace existing claims or use reserved names (iss, sub, aud, exp, iat, nbf, jti, azp, scope, nonce, auth_time, at_hash, c_hash, acr, amr, sid, orgs, or anything starting with org_). Doing so, a hook error or a timeout fails the token request with server_error. See HTTP hooks.

Subject identifiers

sub is chosen per client by two settings, on the client's Subject identifiers tab (setSubject), through admin GraphQL (setClientSubject), or with subject_type at dynamic registration:

Subject type Subject scope sub

public (default)

global (default)

The user id, U + 32 characters. Same for every client

pairwise

global

S..., one per user and sector

public

organization

S..., one per user and active organization. The user id when no organization is active

pairwise

organization

S..., one per user, active organization and sector. The pairwise global value when no organization is active

  • S... values (S + 32 characters) are random, stored and reused, so they are stable but cannot be correlated across sectors or organizations.

  • Sector: clients created on the account pages have their own sector (urn:pykrete:client:<client_id>), so pairwise subjects differ per client. Share subjects with puts a client into the sector of another client of the same owner, so both see the same sub. Dynamically registered clients use the host of their redirect URIs or of sector_identifier_uri.

  • The same sub appears in ID tokens, access tokens, userinfo and introspection. Hooks always get the raw user id.

  • Changing these settings changes every sub the client has seen. Once the client issued tokens or got consent, the change needs force: true ("Change even if users already signed in").

  • The Backend API requires public + global. Per-organization subjects for NetBird are described in Organizations.