On-Behalf-Of (OBO) Integration Guide

OBO lets API A call API B as the current user, with A recorded as the acting client. Signet issues both tokens; you do not need Microsoft Entra or an Entra token. This implementation supports single-hop standard OBO, not Agent OBO.

When to Use It

Scenario Suitable flow
A user-facing BFF calls an orders API using that user’s permissions OBO: BFF is A, orders API is B
A support assistant or MCP gateway calls tools on behalf of a signed-in user OBO if A is a registered confidential client and the user has consented
A scheduled job acts as the application, without a user Client Credentials, not OBO
A frontend needs to sign a user in and obtain its first token Authorization Code + PKCE or Device Flow, before OBO
A chain needs A → B → C, or Entra agent identities/blueprints Not supported by this OBO implementation

Do not forward the original A-audience token to B. OBO creates a separate B-audience token with explicitly approved scopes; B must still check the user’s permissions for the requested order.

sequenceDiagram participant F as Frontend F participant A as API A participant S as Signet participant B as API B Note over F,S: User consents to F → A and A → B F->>A: User access token (aud=A) A->>S: OBO: A credentials + user token + resource B + scope S->>S: Check policy, source token, both consents S-->>A: User access token (aud=B, actor=A) A->>B: Bearer OBO token B->>B: Validate token and user permissions

1. Administrator Setup

This example uses Signet at https://signet.example.com. Replace client IDs, callbacks, API resources, and secrets with your deployment values.

Role Registration / responsibility
Frontend F Authorization Code + PKCE (or Device Flow); registered scope orders.delegate.read; allowed resource https://api-a.example.com
API A Active, non-CIMD confidential client; secret stored on the backend; registered scope orders.read; allowed resource https://api-b.example.com; Authorization Code flow and registered callback for downstream consent
API B Resource server that validates tokens for https://api-b.example.com; its own confidential credentials if it calls introspection
Signet administrator Enables OBO and assigns delegation policies; applications cannot self-assign inbound audience ownership

There is no per-client OBO checkbox: the administrator policy grants delegation authority. A does not need Client Credentials flow enabled to perform OBO.

Save this JSON as /etc/signet/obo-policies.json on the Signet server (mount it read-only if using containers):

[
  {
    "id": "orders-a-to-b",
    "actor_client_id": "API_A_CLIENT_ID",
    "inbound_audience": "https://api-a.example.com",
    "target_resource": "https://api-b.example.com",
    "scope_mapping": {
      "orders.read": ["orders.delegate.read"]
    }
  }
]

scope_mapping maps output scope → all required input scopes. Here, a source token containing orders.delegate.read can request orders.read, provided A’s registration and the user’s downstream consent also allow it. It does not copy every input scope.

Configure Signet:

OBO_ENABLED=true
OBO_TOKEN_EXPIRATION=5m
OBO_POLICIES_FILE=/etc/signet/obo-policies.json

OBO is disabled by default. Empty policies deny every exchange. Policies load at startup: restart every replica with the same configuration and policy file. The duration must be positive and no more than five minutes.

Resource strings must match exactly, including trailing slashes. Use HTTPS; HTTP is allowed only for localhost, 127.0.0.1, or ::1 development hosts. Inbound A and target B must differ. A’s allowed-resources list alone does not establish ownership of audience A.

2. Obtain the User’s Source Token (F → A)

Send the user’s browser to the following authorization request. Line breaks are for readability; construct one URL with encoded query parameters.

GET /oauth/authorize
  ?response_type=code
  &client_id=FRONTEND_CLIENT_ID
  &redirect_uri=https%3A%2F%2Ffrontend.example.com%2Fcallback
  &scope=orders.delegate.read
  &resource=https%3A%2F%2Fapi-a.example.com
  &state=F_RANDOM_STATE
  &code_challenge=F_S256_CHALLENGE
  &code_challenge_method=S256

Follow Authorization Code + PKCE to generate a fresh verifier/challenge, handle the callback, verify state and exact iss, and exchange the code at /oauth/token. Store the resulting access token as USER_ACCESS_TOKEN_A. F sends it in Authorization: Bearer ... when calling A.

The token must have exactly one audience, https://api-a.example.com, and recorded user consent. Its client_id is F, not A. Do not substitute an ID token, refresh token, API key, client-credentials token, external token, or a legacy token without audience/consent.

The same user must also authorize A to access B. A starts a separate browser authorization flow:

GET /oauth/authorize
  ?response_type=code
  &client_id=API_A_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fapi-a.example.com%2Fcallback
  &scope=orders.read
  &resource=https%3A%2F%2Fapi-b.example.com
  &state=A_RANDOM_STATE
  &code_challenge=A_S256_CHALLENGE
  &code_challenge_method=S256

A handles its registered callback and completes the normal authorization-code flow with A’s credentials and its own PKCE verifier. Validate state and iss; bind this flow to the original user session and reject a different signed-in user. Never reuse F’s code or PKCE verifier.

This establishes the user/A/resource-B consent record. The access token from this setup flow is not the OBO assertion: the assertion remains F’s token addressed to A from step 2.

Both consents must remain active and allow the requested scopes. /oauth/token cannot show a consent page. If consent is missing or revoked, arrange this interactive flow before retrying; do not retry invalid_grant in a loop. Multi-resource consent is not a substitute for the separate A → B resource set in this example.

4. Exchange on A’s Backend

Set SIGNET_URL=https://signet.example.com, A’s client ID/secret, and USER_ACCESS_TOKEN_A in your backend’s secure configuration/runtime. Never expose A’s secret to the browser or log either token.

# Run on API A's backend; populate these variables securely.
curl --request POST "$SIGNET_URL/oauth/token" \
  --user "$API_A_CLIENT_ID:$API_A_CLIENT_SECRET" \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
  --data-urlencode 'requested_token_use=on_behalf_of' \
  --data-urlencode "assertion=$USER_ACCESS_TOKEN_A" \
  --data-urlencode 'resource=https://api-b.example.com' \
  --data-urlencode 'scope=orders.read'

All five form parameters shown are required. Send exactly one resource in the form body; do not append query parameters to the /oauth/token endpoint URL. A query inside the form’s resource URI is different: prefer query-free resource identifiers, but if a query is necessary, the full URI (including the query or trailing ?) must exactly match the policy, allowlist, and consent. Basic authentication can be replaced with form-body client_id and client_secret, but never mix the two methods. Duplicate or unsupported parameters, including extra_claims, actor_token, and client_assertion, are rejected. This is not a JSON endpoint.

Example response:

{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "orders.read"
}

expires_in can be less than 300: the lifetime is capped by the source token’s remaining lifetime, A’s token profile, and OBO_TOKEN_EXPIRATION. There is no refresh token or ID token.

5. Call and Protect B

A extracts access_token from the response as OBO_ACCESS_TOKEN_B, then calls B:

curl https://api-b.example.com/orders \
  --header "Authorization: Bearer $OBO_ACCESS_TOKEN_B"

B must verify the signature, expected Signet issuer, expiry, type=access, its own aud, required scope, and user/actor claims. Relevant claims are:

Claim Meaning
sub / user_id Original user
client_id A’s client ID
act.sub client:<A client ID>
aud B’s resource URI
scope Approved downstream scopes, e.g. orders.read

Use JWT Verification, preferably RS256/ES256 with public JWKS. Do not distribute Signet’s HS256 signing secret to independent APIs. B must also enforce application authorization, such as whether this user may read this particular order.

For immediate revocation, additionally call /oauth/introspect with B’s confidential credentials for each protected operation, without caching positive responses. See Tokens & Revocation. With INTROSPECTION_REQUIRE_OWNERSHIP=true, a different client B receives only active for A’s token: keep local JWT validation for audience and claims. active=true alone is not authorization. Reject the operation if introspection fails, times out, or is rate-limited.

Online validation checks the source token, user, both clients, both consents, and current policy. Offline JWT verification alone cannot notice revocation before expiry.

Renewal, Caching, and Troubleshooting

The source token can be reused for exchanges while valid. When a B token expires, A exchanges again using a still-valid source token. If the source expires, F must renew it using its original flow. An OBO result cannot be exchanged again: no multi-hop delegation.

If caching B tokens, isolate entries by user, source-token identity, actor client, target resource, and requested scopes; expire them before the returned lifetime. Never share a token between users or persist raw assertions/secrets in logs or cache keys. A’s token cache does not replace B’s live revocation check.

Error What to check
unsupported_grant_type Enable OBO on all Signet replicas
invalid_request Form encoding, five required fields, duplicate/unknown fields, query parameters
invalid_client (401) A’s active registration, secret, and one authentication method
unauthorized_client A must be confidential/non-CIMD; matching administrator policy must exist
invalid_grant Source token validity and single audience, active user/clients, both consent records
invalid_scope Output-to-input scope mapping, A’s registered scopes, downstream consent
invalid_target Exact B URI, permitted URL format, A’s allowed resources

Do not repeatedly retry authorization errors unchanged. Complete consent or correct configuration; for transient failures use bounded backoff and never fall back to app-only access to bypass a user’s denied permissions.

Scope and Operational Safety

  • Upgrade the database before deploying this version, even with OBO_ENABLED=false: ordinary token inserts need the new columns too. Default startup migration handles this. With DB_AUTO_MIGRATE=false, the migration owner must first add nullable text columns access_tokens.source_token_id and access_tokens.delegation_policy_id, plus index idx_access_tokens_source_token_id on source_token_id. Back up and rehearse first; inspect existing columns/indexes before retrying. See the repository’s SQL upgrade and verification runbook. Verify ordinary issuance before enabling OBO; keep OBO disabled while old replicas remain. Retain the additive schema on rollback.
  • Signet uses jwt-bearer + requested_token_use=on_behalf_of with mandatory resource. This does not promise full MSAL compatibility or implement RFC 8693’s token-exchange request format.
  • Agent OBO, agent blueprints, T1, inherited permissions, .default, and Entra tokens/claims challenges are outside this feature.
  • act and may_act are issuer-reserved claims across all grants, even with OBO disabled. Rename existing custom claims using these names.
  • Turning off OBO_ENABLED on every replica stops issuance and online acceptance. Offline consumers can still accept unexpired tokens. Before downgrading to a pre-OBO version, revoke delegated tokens and wait their maximum lifetime (five minutes); retain the additive schema.

Manage policies without restarts

The default OBO_POLICY_SOURCE=file keeps the original file workflow.
OBO_POLICY_SOURCE=database uses the primary database exclusively and rejects a
simultaneous policy file. Administrators open API resources and delegation
from a Client detail page, register API-local scopes and audience ownership,
and configure explicit output-to-input scope mappings. Client scopes and
allowed resources still need separate configuration; policy edits never grant
user consent. Permission edits, disable and reenable invalidate old OBO tokens
online using policy versions. Display-name changes do not revoke tokens.

Create database policies through the administrator interface.
Stop/drain every OBO replica and wait five minutes before switching sources.
Never mix file/database replicas or silently fall back to an old file. Keep
additive schema during rollback and review effective rules before reenabling.

With OBO_ENABLED=true, OBO_COMBINED_CONSENT_ENABLED=true in database mode and
LOGIN_SESSION_TRACKING_ENABLED=true, administrators can bind F and inbound A
to explicit downstream policy IDs/scopes. A single-resource authorization-code
request displays F→A and A→B together. Approval creates independent grants and
only F’s original code for A; A needs no consent callback and still authenticates
its OBO exchange. Device Flow retains independent consent. This does not add
MSAL, Entra token acceptance or administrator consent for all users.

The server binds a ten-minute single-use handle to the user/session, rechecks
the permission snapshot and commits grants/code atomically. Changed requests
must be restarted. Fully covered grants are preserved; adding scopes can
invalidate old tokens. SkipConsent never creates downstream consent. Grants
remain separately revocable on the account page; revoking A→B also affects other
frontends using the same user/API grant.

Database mode currently reads complete registry snapshots. Use online validation
for immediate OBO revocation; offline JWTs and ordinary non-OBO tokens retain
their existing behavior. Deployment schema SQL, rollback steps and PostgreSQL
verification commands are in the repository’s docs/ON_BEHALF_OF_FLOW.md.