Access and authorization API reference
All Gate API endpoints are served from https://api.vernesoft.com.
This API is divided into two logical parts:
- Identity Management: Tenant-scoped proxy to Kratos Admin API for managing end-users.
- Access & Authorization: Core services for token exchange and permission checks.
Access & Authorization
Create Access Token
POST /v1/gate/tokens
Exchanges a long-lived API key for a short-lived access token that you can attach to backend or CLI requests.
The key goes in the Authorization: Bearer <key> header, as on every other call of this API, and must carry the gate scope. It is not a field of the body: a request with the key only in the body has no key at all and is answered 401 unauthorized.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
subject | string | Yes | ID of the user or service account this token represents. |
scopes | string[] | No | Optional list of scopes to narrow the token (e.g. ['gate.tokens.read']). |
ttl_seconds | integer | No | Lifetime in seconds, from 1 to 86400 (one day). Default 3600. |
A body that is not JSON, has no subject, or has a ttl_seconds that is not a whole number from 1 to 86400 is 400 invalid_payload; the message names the allowed range. A ttl_seconds outside it is refused, not corrected: 0 or a negative number is not turned into a token that has already expired, and a value above 86400 is not cut down to a day.
Example Request
curl -X POST https://api.vernesoft.com/v1/gate/tokens \
-H 'Authorization: Bearer vrn_9f8a7...' \
-H 'Content-Type: application/json' \
-d '{
"subject": "usr_123",
"scopes": ["gate.tokens.read"],
"ttl_seconds": 3600
}'
Response
{
"access_token": "gat_live_5f0c1a9e8b7d4c3a9e2f1b0d6a8c7e45",
"expires_at": "2026-03-17T12:00:00Z",
"subject": "usr_123",
"tenant_id": "ten_001"
}
Introspect Token
POST /v1/gate/tokens/introspect
Validates a Gate access token and returns its decoded attributes.
active is false when the token is unknown, expired, belongs to another tenant, or its subject is an end user you have deactivated or deleted. A deactivated user's tokens become active again when you reactivate them.
When the token is active and its subject is an end user's id, the answer also lists the user's organizations and roles in orgs — read at the moment you ask, so a role you removed a minute ago is already gone from it (a signed token made earlier would still carry it). The list is for the project the API key belongs to, with the role the user holds in each and its permissions, the first 100 in the order the user joined; "orgs_truncated": true appears, and only then, when there are more, and GET /v1/gate/identities/{id}/organizations returns them all. A user who belongs to none gets "orgs": []. A subject that is not an end user's id (a service account's name, say) has no orgs member at all. If the organizations cannot be read at that moment, the answer is 502 upstream_unavailable — never an active answer with the list silently missing.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
access_token | string | Yes | The token you want to introspect. |
Example Request
curl -X POST https://api.vernesoft.com/v1/gate/tokens/introspect \
-H 'Authorization: Bearer vrn_9f8a7...' \
-H 'Content-Type: application/json' \
-d '{
"access_token": "gat_live_5f0c1a9e8b7d4c3a9e2f1b0d6a8c7e45"
}'
Response
{
"active": true,
"subject": "usr_123",
"tenant_id": "ten_001",
"scopes": ["gate.tokens.read"],
"expires_at": "2026-03-17T12:00:00Z"
}
Authorize Action
POST /v1/gate/authorize
Asks Gate whether a subject may perform an action on a resource. The answer is a decision, allowed or not, with a reason; Gate does not enforce it, your service does — it is the check you call before you act.
There are two questions, told apart by resource:
resourceisorganization:<organization id>— may this user do this in that organization? Answered from the user's role in the organization and that role's permissions, as they are right now. See Organizations and Roles.- Any other
resource— is this subject one of your active end users? This is the only policy there is for other resources.actionandresourceare recorded in the decision log and not evaluated; there are no per-resource or per-action rules to configure.
The key needs the gate scope and, for organizations, the answer is about the project the key belongs to.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
subject | string | Yes | The end user's identity id (a UUID). |
action | string | Yes | For an organization: the permission to check, such as invoices:write. Otherwise a label for your own records. |
resource | string | Yes | organization:<uuid> to check a permission in an organization; anything else for the ownership check. |
context | object | No | Accepted and ignored. Nothing is evaluated from it today. |
Example: a permission in an organization
curl -X POST https://api.vernesoft.com/v1/gate/authorize \
-H 'Authorization: Bearer vrn_9f8a7...' \
-H 'Content-Type: application/json' \
-d '{
"subject": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"action": "invoices:write",
"resource": "organization:3b2f6f0e-8f0c-4a54-9f1d-0c7a5d1e2b10"
}'
Response
{
"allowed": true,
"decision_id": "dec_9f8a7c1b2d3e",
"reason": "member_with_permission",
"role": "admin"
}
decision_id identifies the decision in Gate's logs, for support. For an organization, reason is one of a fixed set that your code can branch on:
reason | allowed | Meaning |
|---|---|---|
member_with_permission | true | The user is a member and the role they hold lists the permission. |
member_without_permission | false | The user is a member; their role does not list the permission. |
not_a_member | false | The user is not in the organization — never was, was removed, or is not an end user of yours. |
subject_inactive | false | The user is a member but is switched off, so authorized for nothing. |
no_such_organization | false | The key's project has no organization with that id. |
role — the name of the role the user holds there — is present for member_with_permission, member_without_permission and subject_inactive, and absent otherwise.
- Exact match. The permission must be one the role lists, character for character. There are no wildcards:
invoices:*is not a permission,invoicesdoes not coverinvoices:write, and a role holdinginvoices:write:alldoes not holdinvoices:write. - Live. The answer is read when you ask: a permission added to a role, a user moved to another role, a membership removed — the next call has it.
- Another project's organization looks like a missing one. An id that belongs to another project of yours, to another account, or to nobody is answered
no_such_organizationin every case, with nothing to tell them apart. - An invalid permission is a
400, not a denial. A string that no role could ever hold — upper case, an empty segment, a wildcard, the reservednautilus:prefix, over 64 characters — is400 invalid_payloadwith the reason inmessage, so a typo is not mistaken for "the user lacks it". So is anorganization:resource that does not end in a UUID. - If Gate cannot reach what it needs to answer, you get a
5xx, neverallowed: false. - Asking writes nothing, the audit log included, and the call is not billed.
The ownership check (any other resource)
curl -X POST https://api.vernesoft.com/v1/gate/authorize \
-H 'Authorization: Bearer vrn_9f8a7...' \
-H 'Content-Type: application/json' \
-d '{ "subject": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "action": "read", "resource": "doc:1" }'
{
"allowed": true,
"decision_id": "dec_9f8a7c1b2d3e",
"reason": "owned_by_tenant",
"message": "subject `f47ac10b-58cc-4372-a567-0e02b2c3d479` is an identity owned by tenant `550e8400-e29b-41d4-a716-446655440000`"
}
allowed is true when subject is the id of an active end user of your account, and false for an id that is someone else's, one that does not exist, one that is not an id at all (usr_123), and a user who is switched off.
Here too reason is a code your code can branch on:
reason | allowed | Meaning |
|---|---|---|
owned_by_tenant | true | The subject is an active end user of your account. |
subject_inactive | false | The subject is your end user but is switched off. The same code the organization check uses for the same fact. |
not_owned_by_tenant | false | The subject is an identity, and it is another account's. |
not_a_known_identity | false | Nothing has that id: it does not exist, was deleted, or is not an id at all (usr_123). |
message holds the sentence a person would read, for your logs. It is a courtesy: its wording can change, and only reason is a contract. A decision about an organization has no message; its reason says it all.
Changed in October 2026. Until then
reasonfor this check was that sentence itself (subject `…` is an identity owned by tenant `…`). If your code matched on its text, match onreasoninstead:owned_by_tenantisallowed: true, and every other value is a denial.
Error Format
The Gate API uses the standard error envelope, on every route:
{
"error": {
"code": "invalid_payload",
"message": "`subject` is required",
"request_id": "req_abc123"
}
}
code is always a string (invalid_payload, unauthorized, forbidden, not_found, conflict, …). A malformed body on POST /v1/gate/tokens, /tokens/introspect and /authorize is 400 invalid_payload, as everywhere else; it was invalid_request, without a request_id, until October 2026. See Error Format for the full list.
Include the request_id when contacting support for faster resolution.