Skip to content

Service Grants ​

Service tokens carry grants that control which API operations the service can perform. This page lists every grant and its scope.

GrantAllows
introspectVerify and inspect sessions and tokens
identity_readRead identity data (lookup by ID, search by username)
identity_writeUpdate identities (profile update, enable/disable)
direct_authAuthenticate users directly (password verification)
revokeRevoke sessions and tokens
browser_proxyFront the browser perimeter on behalf of end users
external_authSign users in through an external provider (email, OAuth), creating the identity on first sight
external_linkAttach an external provider account to an existing identity, or detach one
oauth_adminRegister and revoke OAuth clients, and register the audiences tokens can be scoped to

Grant-to-endpoint mapping ​

Each grant unlocks a specific set of service API endpoints:

GrantEndpoints unlocked
introspectPOST /v1/sessions/verify — verify a session token; inspect session metadata
identity_readGET /v1/identities/{id} — get identity by UUID; GET /v1/identities?username=... — search by username; GET /v1/identities/{id}/links — list linked provider accounts
identity_writePATCH /v1/identities/{id} — update profile fields
direct_authPOST /v1/authenticate/password — authenticate with username + password; POST /v1/register — create an identity directly
revokePOST /v1/sessions/revoke — revoke a single session; POST /v1/identities/{id}/revoke-sessions — revoke all sessions for an identity
browser_proxyThe browser endpoints (/v1/login, /v1/flows/..., /v1/whoami, ...) — permits stating the end user's address in X-Surge-Client-Ip
external_authPOST /v1/authenticate/link — resolve a (provider, subject) pair to a session, creating the identity if the link is new
external_linkPOST /v1/identities/{id}/links — attach or confirm a link; DELETE /v1/identities/{id}/links — detach one
oauth_adminPOST /v1/oauth/clients — register an OAuth client; GET /v1/oauth/clients — list them; DELETE /v1/oauth/clients/{client_id} — revoke one; POST /v1/oauth/resources — register this service's audience; GET /v1/oauth/resources — list audiences

A request to an endpoint without the required grant returns 403 Forbidden:

json
{
  "error": {
    "type": "forbidden",
    "message": "service token does not have the required grant: identity_write",
    "details": {}
  }
}

Grant design: least privilege ​

Grants follow the principle of least privilege. A service token should carry only the grants it needs for its specific role. If a service only verifies sessions, give it introspect — not identity_write or direct_auth.

A compromised token with only introspect can verify sessions; one with identity_write and revoke can disable accounts and force-logout every user. Grant scope accordingly.

The browser_proxy grant ​

browser_proxy is what RemoteProvider::browser_router() uses. It does not unlock any privileged operation directly — the browser endpoints are public. What it authorizes is the statement a proxy makes about who its request is for: with it, X-Surge-Client-Ip becomes the key rate limiting is applied under.

A compromised browser_proxy token can therefore pick which bucket its requests count against, evading per-IP rate limits on login. Give it only to services that actually mount a remote-mode browser router.

The external grants assert proof of control ​

Surge stores links — ("email", "[email protected]"), ("google", "117...") — but it never talks to a provider itself. It sends no mail and performs no OAuth code exchange, so it cannot check that the person in front of your service actually controls the subject being claimed. The external_auth grant is that assertion: holding it means the service has already completed the handshake before calling.

POST /v1/authenticate/link therefore mints a session from a (provider, subject) pair with no secret in the request — for the email provider, the subject is the user's public address. A compromised external_auth token can sign in as any identity with a verified link, so it belongs only on the service that terminates the provider handshake, and nowhere else.

external_link is separate because attaching a subject to an existing identity is account-binding: a service able to link google:[email protected] to someone else's identity could then sign in as them. Signing users in through a provider does not require binding new providers to established accounts, so the two capabilities stay apart.

direct_auth remains scoped to password authentication and unlocks neither.

The oauth_admin grant registers clients, and introspect reads their tokens ​

oauth_admin is deliberately separate from identity_write: registering an OAuth client is not an identity operation, and a service that manages users has no business minting clients. The flag that makes it sensitive is first_party, which skips the consent screen — a first-party client obtains a user's tokens with no approval step, so the grant that can set it stays narrow. Dynamically registered clients can never be first-party, whatever grant asked.

A service registers its own audience: the owning service_id on POST /v1/oauth/resources is taken from the authenticated token, never from the body. That is what keeps the introspection check meaningful — POST /oauth2/introspect is authenticated by a service token with introspect, and answers only about tokens whose audience that service owns. Any other service gets active: false, not someone else's token contents.

A link carries a verified_at timestamp, and only a verified link can authenticate. An unverified link is a pending claim — an address someone typed into account settings that nobody has proven control of — and POST /v1/authenticate/link will not resolve it.

That distinction is what makes the two-step email flow safe to build:

bash
# 1. User adds an address in settings. Nothing is proven yet, so verified=false.
curl -X POST http://localhost:3000/v1/identities/$ID/links \
  -H "Authorization: Bearer aeg_svc_..." \
  -d '{"provider": "email", "subject": "[email protected]", "verified": false}'

# 2. Your service mails a token. When it comes back, re-post the same link
#    with verified=true — the call is idempotent and only refreshes the flag.
curl -X POST http://localhost:3000/v1/identities/$ID/links \
  -H "Authorization: Bearer aeg_svc_..." \
  -d '{"provider": "email", "subject": "[email protected]", "verified": true}'

Between those two calls the address is inert: it cannot sign anyone in, and it cannot be used to recover the account. Re-posting a subject that is already linked to a different identity fails rather than moving it.

The revoke grant is the most sensitive ​

The revoke grant allows a service to revoke any session and all sessions for any identity. A compromised token with revoke can force-logout every user. Treat this grant as high-privilege and apply it sparingly.

Assigning grants ​

Grants are assigned at token creation time via the CLI and cannot be changed after creation:

bash
# Create a token with specific grants
surge-server svc create --name "my-gateway" --grant introspect --grant identity_read

If a service's role changes and it needs different grants, create a new token with the updated grant set and revoke the old one:

bash
# Old gateway needs identity_write now
surge-server svc create --name "my-gateway-v2" --grant introspect --grant identity_read --grant identity_write
surge-server svc revoke my-gateway

Grants are not mutable because changing them would change the semantics of an already-deployed token — safer to rotate.

Combining grants for specific service roles ​

Common service roles and their grant sets:

RoleGrants neededWhy
API gateway (verify-only)introspectVerify session tokens from incoming requests, nothing more
API gateway (with identity)introspect, identity_readVerify sessions and enrich requests with user profile data
User management serviceidentity_read, identity_writeLook up, create, update, and disable identities
Admin panelidentity_read, identity_write, revokeFull identity management plus session revocation
Auth proxydirect_authAccept username/password and return session tokens
Remote-mode web appintrospect, browser_proxyVerify sessions, and reverse-proxy the browser perimeter for its own users
OAuth / email sign-in serviceexternal_authTerminates the provider handshake, then resolves the resulting (provider, subject) to a session
Account settings serviceidentity_read, external_linkLets a signed-in user list, attach, and disconnect provider accounts
Full-access system serviceAll of themInternal service that needs everything — use with extreme caution

Example: API gateway with session verification and user enrichment ​

bash
surge-server svc create --name "api-gateway" --grant introspect --grant identity_read

This service can:

  1. Call POST /v1/sessions/verify to validate incoming session tokens
  2. Call GET /v1/identities/{id} to get the user's display name and avatar

It cannot create, disable, or revoke anything.

Example: Internal admin service ​

bash
surge-server svc create --name "admin-cli" --grant identity_read --grant identity_write --grant revoke

This service can:

  1. Look up identities
  2. Disable and enable user accounts
  3. Revoke sessions

It cannot authenticate users (direct_auth) or verify sessions (introspect) — those aren't part of its responsibility.

Audit trail ​

Every action performed with a service token is recorded in the audit log with the service's name and ID. This creates an accountability trail:

json
{
  "action": "identity_disable",
  "service_name": "admin-cli",
  "service_id": "018f9a1b-...",
  "target_identity": "018f9a1b-...",
  "timestamp": "2026-07-08T12:00:00Z"
}

Related: Service Authentication, CLI