Browse the docs
Concepts

Tokens & token exchange

OrthID tokens are signed JWTs. They prove who an actor is, what it may do, and - for agents - on whose behalf it acts.

Every authenticated request carries a token. OrthID issues two kinds: a short-lived access token that authorises requests, and a longer-lived refresh token that keeps the session alive. Both are JWTs signed with keys held in your region, so they can be verified anywhere without a network round-trip.

JWT structure and claims

A decoded access token holds the standard registered claims plus the OrthID claims your authorisation logic relies on. The key ones:

  • sub - the principal id (the actor).
  • typ - the actor type: user, organization or agent.
  • org - the active organisation.
  • scope - the granted permissions.
  • act - present on agent tokens; records the human the agent acts on behalf of.
  • region, iat, exp - residency and validity window.
decoded access token (payload)
{
  "iss": "https://au-syd-1.orthid.com",
  "sub": "agent_7xQ1vD",
  "typ": "agent",
  "org": "org_2bT7uX",
  "scope": ["records:read", "summaries:write"],
  "act": {
    "sub": "user_3kP9aZ",
    "typ": "user"
  },
  "region": "au-syd-1",
  "iat": 1750595400,
  "exp": 1750596000
}

Access vs refresh tokens

The two token types do different jobs:

  • Access token - sent on every request as a Bearer token, verified with orthid.sessions.verify. Short lifetime (minutes) so a leaked one expires fast.
  • Refresh token - never sent to your APIs. The SDK uses it to mint a new access token when the old one expires. Revoking it ends the session immediately.

Token exchange for on-behalf-of agents

Agent delegation uses OAuth 2.0 Token Exchange (RFC 8693). A human’s access token is exchanged for a new, narrower agent token. The agent token carries an act claim that names the human, and its scopecan only be a subset of the human’s. The agent gets exactly what it needs, for a limited time, and nothing more.

server: issue an agent token
import { orthid } from "@orthid/sdk";

// Exchange the human's session for a scoped, expiring agent credential.
const agent = await orthid.agents.issue({
  onBehalfOf: "user_3kP9aZ",       // the principal human
  scope: ["records:read", "summaries:write"],
  ttl: "10m",
  region: "au-syd-1",
});

// agent.token is a JWT carrying an "act" claim for user_3kP9aZ
console.log(agent.token, agent.expiresAt);

Under the hood this is a token-exchange request. The agent token is minted with act.sub set to the human and a scope that the authorisation server has confirmed is allowed for that human:

POST /oauth/token (RFC 8693)
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<human_access_token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&requested_token_type=urn:ietf:params:oauth:token-type:access_token
&scope=records:read summaries:write
&audience=agent_7xQ1vD

How provenance is recorded

The act claim is the chain of accountability. Because it travels inside the signed token, every downstream service sees both the acting agent (sub) and the human it acts for (act.sub) without trusting any extra header. OrthID writes the same pair into the audit log, so orthid.audit.list()can always answer “which agent did this, and on whose behalf?”

Scope can only shrink
Token exchange can never grant an agent more than its principal human holds. If you request a scope the human lacks, the exchange is rejected. Design agent scopes as a deliberate subset.

Next steps