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 |
|
Identifier |
Unique free-form name, for example |
Name, description, picture |
Shown on the consent screen |
Access token signature |
|
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
audienceper 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 thataudcontains their Audience ID. -
An unknown
audiencefails the request withinvalid_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 |
|---|---|
|
"View and manage your account": the token can call |
|
Full admin GraphQL access. Held through the |
|
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 |
|---|---|---|
|
User id |
An ID token is issued; |
|
Profile |
|
|
|
|
|
Offline access |
A refresh token is issued, see Refresh tokens |
|
Organizations |
|
|
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/revokeRoleon 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 |
|---|---|
|
|
|
All |
|
None; organization-assignable |
The scopes a user may receive for audience A are therefore:
-
all scopes of
Ain the user's global roles, plus -
all scopes of
Ain the user's member roles in the active organization, if any, plus -
account, ifAis 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
audienceparameter. 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 requiresaccountthis way). -
Restrict who can use an app: if the client requires
(A..., use:app), everyone without a role carrying that scope getsinvalid_scopeat 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:
-
Split
scopeon spaces and add the client's required scopes (and their audiences). -
Builtin scopes always pass.
-
app_key:<id>passes if the client is allowed that app key. -
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.
-
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.
Consent
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 getserror=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 |
|---|---|
|
Always, |
|
Always, see Subject identifiers |
|
Always, the client id (a string) |
|
Always; valid for 1 hour |
|
If sent to |
|
|
|
|
|
|
|
An organization is active |
custom |
Returned by the claims hook for |
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 |
|---|---|
|
As in the ID token |
|
Audience IDs: a string for one, an array for several, |
|
The client id |
|
Space-separated granted scopes, builtin and app key scopes included |
|
An organization is active |
custom |
Returned by the claims hook for |
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 |
|---|---|---|
|
|
The user id, |
|
|
|
|
|
|
|
|
|
-
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 samesub. Dynamically registered clients use the host of their redirect URIs or ofsector_identifier_uri. -
The same
subappears in ID tokens, access tokens, userinfo and introspection. Hooks always get the raw user id. -
Changing these settings changes every
subthe client has seen. Once the client issued tokens or got consent, the change needsforce: true("Change even if users already signed in"). -
The Backend API requires
public+global. Per-organization subjects for NetBird are described in Organizations.