How AuthPI embeds active organization memberships in tokens, how selected-org tokens work, and how your resource server authorizes per org.
Last updated 2026-06-20
If you’re coming from Auth0 Organizations or WorkOS, you may expect every token to be bound to one organization: the user authenticates into an org, the token carries that org’s ID and role, and switching orgs means another round trip through the identity provider.
AuthPI’s default is multi-org: user access tokens and ID tokens embed the user’s active organization memberships. One session serves all tenants a user belongs to, and your API decides—per request—which membership applies.
For clients that need narrower tokens, AuthPI also supports selected-org issuance. Add org=org_... to the authorization request to restrict the issued token set to that organization. Client settings can further restrict organization claims to all orgs, no orgs, or an allowlist.
When AuthPI issues user access tokens and ID tokens, it loads the user’s active memberships and applies the client’s organization restrictions. Suspended memberships are excluded—only active ones make it into tokens. Memberships of suspended organizations are excluded the same way: suspending an org stops its claims from entering any newly issued token.
The organizations claim has the same array shape in access tokens, ID tokens, and /userinfo responses:
"organizations": [
{
"id": "org_0gw3hcq8r2kfn7xj9tzm4be5a",
"title": "Founder",
"scopes": ["owner", "billing:write"],
"joined_at": 1767312000
},
{
"id": "org_0hk2tqvw8m3rfe9pjx5zcn4ba",
"title": null,
"scopes": ["member", "projects:read"],
"joined_at": 1773100800
}
]
Refresh tokens do not carry full organization membership claims. If the authorization request used org=org_..., the refresh token carries only an org_id marker so refresh can keep issuing selected-org tokens after re-reading current memberships.
See the token claims reference for every claim in each token type.
To ask for a token set restricted to one organization, include org in the authorization request:
GET /authorize?
response_type=code&
client_id=c_...&
scope=openid%20profile&
org=org_0gw3hcq8r2kfn7xj9tzm4be5a
AuthPI validates the selected organization at token issuance. If the user is not an active member, the authorization-code exchange fails with invalid_grant. If the client is not allowed to request that organization, the request fails with invalid_request.
Selected-org state is bound to the authorization code and resulting session. The /token request cannot add, remove, or change it.
Clients can restrict organization claims under settings.restrictions.organizations:
{
"settings": {
"restrictions": {
"organizations": {
"policy": "allowlist",
"allowed_org_ids": ["org_0gw3hcq8r2kfn7xj9tzm4be5a"]
}
}
}
}
Policies:
all: include all active memberships unless org selected one.none: issue no organization claims and reject selected-org requests.allowlist: include only active memberships in allowed_org_ids; selected-org requests must name an allowlisted org.The single-org-per-token model forces a question at login that users shouldn’t have to answer: which organization are you here for? AuthPI removes that question entirely:
app.example.com/acme/...) and sends it with each request. No identity-provider round trip.This has two deliberate design consequences:
org for all active memberships allowed by the client. Include org=org_... when you intentionally want a selected-org token set.authorization_code, refresh_token, and client_credentials. You never trade a multi-org token for a single-org one—the resource server picks the relevant entry instead.| Single-org-per-token (Auth0/WorkOS model) | AuthPI | |
|---|---|---|
| Org selection | At login, via an /authorize parameter | Per request by default; optional org parameter for selected-org tokens |
| Switching orgs | New authorization round trip | Client-side state change for multi-org tokens; new authorization when using selected-org tokens |
| Sessions | One per user per org | One per user |
| Token contents | One org ID + role | Active memberships allowed by client settings, or one selected org |
| Org access removal | Revoke that org’s tokens/session | Membership disappears from tokens at next refresh |
AuthPI has no separate role system. A membership carries a scopes array, and a scope is any string (1–100 characters). owner, admin, and member are conventions, not reserved words—they sit in the same array as fine-grained scopes like projects:write or billing:read.
Two conventions you’ll see in practice:
["owner", "*:**"]—the owner role convention plus a wildcard.["member"]) that new members receive on join.Your API defines what these strings mean. owner grants nothing by itself; it grants whatever your authorization code says it grants.
Authorization is two steps: identify which org the request targets (header, path segment, or subdomain—your choice), then find that org in the token and check its scopes.
import { createRemoteJWKSet, jwtVerify } from "jose";
const issuer = process.env.AUTHPI_ISSUER_URL!; // e.g. https://idp.authpi.com/i_8fk2mqzr4tw1ab
const jwks = createRemoteJWKSet(new URL(`${issuer}/jwks.json`));
interface OrgEntry { id: string; scopes: string[] }
export async function authorizeRequest(req: Request, requiredScope: string) {
const token = req.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
const { payload } = await jwtVerify(token, jwks, {
issuer,
audience: "https://api.example.com", // the audience your client requests for this API
});
// The caller declares which org this request is for
const orgId = req.headers.get("x-org-id");
const orgs = (payload.organizations ?? []) as OrgEntry[];
const membership = orgs.find((o) => o.id === orgId);
if (!membership) {
throw new Response("Not a member of this organization", { status: 403 });
}
if (!membership.scopes.includes(requiredScope)) {
throw new Response(`Missing scope: ${requiredScope}`, { status: 403 });
}
return { userId: payload.sub as string, orgId, scopes: membership.scopes };
}
If you treat owner or admin as superseding fine-grained scopes, encode that in your check (scopes.includes("owner") || scopes.includes(requiredScope))—AuthPI doesn’t impose a hierarchy.
Since tokens are snapshots, the natural question is: what happens when someone is removed from an org? The contract is:
refresh_token grant, AuthPI re-reads the user’s memberships from the index—it does not copy them from the old token. A removed or demoted member gets the corrected claims in the very next access token.org, refresh keeps the same org_id restriction. If that membership disappears, the refreshed token has organizations: [] rather than expanding back to all memberships.default_access_token_age), that is the maximum staleness window.For most B2B applications this is the right trade: removal takes effect within minutes, without the operational cost of hunting down sessions. When you need tighter guarantees:
settings.openid.default_access_token_age, in seconds). A 5-minute TTL means a 5-minute worst-case staleness.organization.membership.deleted and organization.membership.updated, and maintain a short-lived server-side denylist of (user, org) pairs that your API consults alongside the token.aud precedence, and TTL configuration