Signed tokens (JWT)
When an end user signs in, Gate gives your backend a session_token. That token is opaque: to find out who it belongs to, something has to ask Gate. This page is about the other representation — a signed JWT for the same session, which anyone can verify without calling Gate, using public keys we publish.
You need it when the thing that has to trust the user can't call us. The usual case is a platform that federates identity by verifying a signature — Supabase's third-party auth, an API gateway, a serverless function that checks a JWT before it runs.
The signed token is additional, not a replacement. The opaque session token and POST /v1/gate/tokens/introspect stay as they are, and they are the ones you can revoke. Read Lifetime and revocation before you build on a signed token.
Get a signed token
Ask Gate for the signed form of a session with the user's session_token:
GET https://api.vernesoft.com/auth/sessions/whoami?tokenize_as=gate_jwt
X-Session-Token: ory_st_...
No API key is needed: the session token is the credential, and it also tells Gate which tenant the user belongs to.
curl 'https://api.vernesoft.com/auth/sessions/whoami?tokenize_as=gate_jwt' \
-H 'Accept: application/json' \
-H 'X-Session-Token: ory_st_...'
The JWT is the tokenized member. The rest of the answer is the session itself, as whoami always returns it.
| Status | Meaning |
|---|---|
200 | The session is valid; tokenized holds a new JWT. Every call mints a new one. |
401 | No session token, or the session is revoked or expired, or its user is deactivated (for as long as they are). No token is issued. The body is the standard error envelope — {"error": {"code": "unauthorized", "message": "That session is not active.", "request_id": "req_…"}}; until October 2026 it was the identity service's own, with a number in code. |
403 | mfa_required — the person has a second factor and this session has not given it. Complete the step-up and ask again. |
502 | Gate could not read the user's organizations at that moment (upstream_unavailable). No token is issued — never one without the claim, which would be indistinguishable from the token of a user who belongs to none. Retry. |
Call it from your backend, or wherever you already keep the session token, and ask again before the token you hold expires. Do not store signed tokens as if they were sessions.
What is in the token
A compact JWT signed with ES256 (ECDSA, P-256, SHA-256).
Header
| Member | Value |
|---|---|
alg | ES256 |
typ | JWT |
kid | The id of the signing key. Use it to pick the matching key from the key set. |
Payload
| Claim | Meaning |
|---|---|
iss | The issuer — one per tenant: https://api.vernesoft.com/auth/{tenant_id}. See The issuer is yours alone. |
sub | The end user's identity id, the same id the identity endpoints return. Key your own records on this. |
sid | The id of the session the token was made from — the id you revoke with DELETE /v1/gate/sessions/{id}. |
tenant_id | Your tenant id. |
schema_id | user for an end user. |
email | The user's email address, or null if the user has none. |
role | Always authenticated. The database role a PostgREST-style consumer such as Supabase runs your user's queries as. |
orgs | The user's organizations and roles: a list of { "id", "role", "permissions" }. Present on every token for an end user, empty when the user belongs to none. |
orgs_truncated | true when the list above is not complete — only then; absent otherwise. See the cap. |
iat, nbf | When the token was issued. They are equal. |
exp | When it stops being valid: 15 minutes after iat. |
jti | A unique id for this token. |
There is no aud claim, and custom metadata is not in the token. If you need something from metadata_public, read it from the identity by sub.
These claims are for your end users. A team member's account is not an end user, has no tenant-scoped issuer, and its token carries no orgs.
A decoded example:
{
"alg": "ES256", "typ": "JWT", "kid": "dMPs6Aog3jLDM8oO0ND9tsIl7xLzBvCajklsBVN3D8k"
}
{
"iss": "https://api.vernesoft.com/auth/550e8400-e29b-41d4-a716-446655440000",
"sub": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"sid": "8e4f0b2a-3c1d-4e5f-9a6b-7c8d9e0f1a2b",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"schema_id": "user",
"email": "ada@example.com",
"role": "authenticated",
"orgs": [
{
"id": "3b2f6f0e-8f0c-4a54-9f1d-0c7a5d1e2b10",
"role": "admin",
"permissions": ["invoices:read", "invoices:write", "members:invite"]
},
{
"id": "9a1c4d52-7e3b-4f68-a2d0-5b8e6c3f1a77",
"role": "viewer",
"permissions": ["invoices:read"]
}
],
"iat": 1791916230, "nbf": 1791916230, "exp": 1791917130,
"jti": "bc38f9c9-e7f5-493a-89f7-8e538df30b66"
}
Organizations and roles
If you group your end users into organizations — their teams, workspaces, companies — the token says which ones a user belongs to and what they may do in each, so a platform that only has the token (a database policy, an API gateway) can decide without calling Gate.
"orgs": [
{ "id": "3b2f6f0e-…", "role": "admin", "permissions": ["invoices:read", "invoices:write"] },
{ "id": "9a1c4d52-…", "role": "viewer", "permissions": ["invoices:read"] }
]
| Member | Meaning |
|---|---|
id | The organization's id, as the organizations API returns it. |
role | The name of the role the user holds in that organization: lowercase letters, digits, _ and -. |
permissions | What that role grants, as you defined them: strings like invoices:write. Gate does not interpret them — your code, or your database policy, does. |
What to rely on:
- All of the user's organizations in your tenant, across all your projects. A signed token cannot know which of your applications will read it, so it does not choose. (The API and introspection, which are called with a key, answer for the key's project only.) A user holds one role per organization.
- There is no "active organization". A user who belongs to three organizations has three entries, and the token does not say which one they are looking at. Your application knows that; it picks the entry by
id. - Always an array on an end user's token. A user who belongs to nowhere gets
"orgs": [], not a missing claim, soauth.jwt() -> 'orgs'andjsonb_array_elementswork without acoalesce. A token without the claim is not one of an end user. - Entries are in the order the user joined, oldest first.
- The names of roles and permissions are the ones you set at the time the token is made, not a snapshot of when the user joined: rename a role or add a permission to it, and the next token carries it.
The cap
A token lists at most 10 organizations, and at most about 4 KB of them (ten organizations with five permissions each take about 1.6 KB; the second limit only matters to a role with dozens of long permissions, and keeps the token small enough for an Authorization header). When the user belongs to more, or the list does not fit, the token carries the first organizations the user joined that fit and "orgs_truncated": true. The flag means: this list is incomplete — ask the API. GET /v1/gate/identities/{id}/organizations returns the complete list, live.
A policy that must never grant on partial information should treat orgs_truncated as "unknown" and refuse, or fall back to your own table, rather than read a missing organization as "not a member".
How fresh it is
The token is made when you ask for it, from the current organizations, roles and permissions. After that it is a signed document, valid for 15 minutes, and nothing recalls it. So:
- A role you remove or change, or a user you take out of an organization, may live on in a token already issued until that token expires — at most 15 minutes. The next token reflects the change.
- A role added is not in tokens already issued either, until the user's next token. Ask for a new one if the change must show at once.
- For anything that must follow a change immediately, don't read the token: ask the live answer — introspection or
GET /v1/gate/auth/session, both of which read the same organizations now — or the organizations API.
Who can see it
The token belongs to the user it is made for, and so is readable by them: a signed token is not encrypted, and anyone who holds it — the user included — can read every claim. The orgs claim therefore shows a user their own organizations, their role names and their roles' permissions, and nobody else's. It shows nothing about the other members of an organization. If a permission's name is something you would not tell your own users, don't call it that.
The issuer is yours alone
Every tenant has its own issuer, built from the tenant id:
https://api.vernesoft.com/auth/{tenant_id}
Your tenant id is on the Dashboard under Settings → Account Identity → Tenant ID, and it is the tenant_id claim of any token Gate gives you.
This is a security boundary, not a naming convention. One set of keys signs tokens for every tenant, so the tenant has to be in the issuer: a token made for another tenant's user carries a valid signature, but its iss names that other tenant, and a verifier that checks iss against your issuer refuses it before it looks at the signature.
Always check iss. Pass your issuer to your JWT library as the expected issuer, as the examples below do. A verifier that skips the check accepts every Gate tenant's users.
Discovery and keys
Two public documents, no authentication, both under the issuer:
| Document | URL |
|---|---|
| OpenID discovery | https://api.vernesoft.com/auth/{tenant_id}/.well-known/openid-configuration |
| Public keys (JWKS) | https://api.vernesoft.com/auth/{tenant_id}/.well-known/jwks.json |
The discovery document names the issuer — exactly as it appears in iss — and the jwks_uri above. It is not a full OpenID provider: Gate has no authorization or token endpoint, only what is needed to verify a token you already hold.
{
"issuer": "https://api.vernesoft.com/auth/550e8400-...",
"jwks_uri": "https://api.vernesoft.com/auth/550e8400-.../.well-known/jwks.json",
"id_token_signing_alg_values_supported": ["ES256"],
"response_types_supported": ["id_token"],
"subject_types_supported": ["public"],
"claims_supported": ["iss", "sub", "aud", "exp", "iat", "nbf", "jti", "sid", "tenant_id", "schema_id", "email", "role", "orgs", "orgs_truncated"]
}
The JWKS is a standard JSON Web Key Set. It holds public keys only, each with a kid, and usually one key — two while a key rotation is in progress. Both documents are served with Cache-Control: public, max-age=300.
Verify a token
Use a standard JWT library with a JWKS client (jose for Node.js and PyJWT for Python below). Give it the JWKS URL and the issuer, allow only ES256, and let it choose the key by kid.
// npm install jose
import { createRemoteJWKSet, jwtVerify } from 'jose'
const TENANT_ID = '550e8400-e29b-41d4-a716-446655440000' // your tenant id
const ISSUER = `https://api.vernesoft.com/auth/${TENANT_ID}`
// Fetches the keys, caches them, and re-fetches when a token names a kid it
// has not seen — which is how it follows a key rotation.
const keys = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`))
export async function verifyGateToken(token) {
const { payload } = await jwtVerify(token, keys, {
issuer: ISSUER, // refuses every other tenant's tokens
algorithms: ['ES256'], // refuses anything else, including "none"
clockTolerance: '10s', // for a clock that runs a little behind ours
})
return payload // payload.sub is the end user's id
}
Both libraries check the signature, exp and nbf, and the issuer. They do not check that the session behind the token is still alive — see the next section.
Lifetime and revocation
A signed token is valid for 15 minutes from iat, and a JWT cannot be revoked. Once it is signed, anyone holding it can use it until exp, and nothing you do on Gate's side reaches the copy they hold. Concretely, against the real identity service:
expis not shortened by the session. A session that ends in two minutes still produces a token valid for fifteen.- A token outlives its session. If you revoke the session, or it expires, or you deactivate the user, Gate stops issuing new tokens at once (
401) — but the tokens already issued keep verifying until their ownexp, at most 15 minutes later. (A deactivated user's session is only suspended: reactivate the user and it yields tokens again.) - Organizations and roles are as of the moment it was made. See How fresh it is: a role removed a minute ago can still be in a token that was issued before.
So treat a signed token as proof of who the user was a few minutes ago, not as a live session:
- Ask for a fresh one a few minutes before the old one expires, and do not cache a token longer than its
exp. - If acting for a revoked or deactivated user for up to 15 minutes is not acceptable, don't rely on the signed token alone. On the actions that matter, check the live state instead: ask
GET /v1/gate/auth/sessionwith the user's session token. It answers401the moment the session is revoked or the user is deactivated, and lists the user's organizations and roles as they are now.
Key rotation
We rotate the signing key rarely, and never without notice to your verifier: the new key is published in the JWKS first, and only after that does it start signing. For a verifier that behaves as the examples above do, rotation is invisible. To be one:
- Select the key by
kid, from the token's header. Never hard-code a key or take "the first key in the set": during a rotation the set has two. - Cache the key set and respect
Cache-Control(five minutes), instead of fetching it for every token. - Re-fetch when you meet an unknown
kid— once, rate-limited. That is the signal that a new key has started signing.joseandPyJWTdo this on their own. - Don't pin keys or copy them into configuration — unless a platform leaves you no choice, in which case you own the update when we rotate.
A new key is published at least 24 hours before it signs its first token, and the old key stays published for at least 24 hours after it stops signing, so tokens signed just before the switch still verify until they expire. A platform that stores your keys itself (Supabase says it re-checks "periodically" and asks you to allow up to 30 minutes) needs that window.
When not to use it
- Authenticating your own backend's calls to the Gate API — that is what your API key is for.
- Anything that must be revocable at once — use the opaque session token and introspection.
- Anything that needs the user's custom metadata — read the identity.
- A decision that must follow a role change at once — read the organizations API or introspection, which are live.
See also: Organizations and roles, Access & Authorization for the opaque token lifecycle and Session Management for revoking sessions.