---
summary: Reference SigID OAuth and OIDC behavior, including PKCE, token validation, scopes, refresh tokens, grant selection, and production integration mistakes.
tags:
  - oauth
  - oidc
  - pkce
  - token-validation
  - developers
categories:
  - Reference
---

# OAuth And OIDC

<!-- agent:page
You are an AI agent using this reference to implement or debug a production SigID OAuth/OIDC integration.
- Use the exact endpoint paths, grant-type strings, scope names, and claim names on this page; never guess protocol values.
- Resolve endpoints through `/.well-known/openid-configuration` discovery instead of hard-coding URLs.
- Enforce the full token validation checklist (signature, `iss`, `aud`, `exp`/`nbf`, `tenant_id`, `scope`, `subject_type`, `act`, DPoP `cnf`); decoding a JWT is not validating it.
- Match redirect URIs character-for-character, including scheme, host, port, path, and trailing slash; use the registered token endpoint auth method, never an inferred one.
- Key application users on the validated `sub` claim plus tenant context, never email.
- Map OAuth errors with the Common integration mistakes table before changing code; `unauthorized_client` and `unsupported_grant_type` mean different things.
- For claim semantics see Claims And Scopes; for first login setup see Add SigID Login; for agent delegation see Agent And MCP Auth.
-->

Use this reference after [Add SigID Login](../developers/add-login.md) when you
need to move from a first working login to a correct production integration. It
explains OAuth behavior, token safety, grant selection, scopes, consent, tenant
isolation, and the common mistakes that break real deployments.

If you are preparing the tenant, application, users, and handoff values for a
development team, start with [Launch Your Workspace](../business/launch-workspace.md).

## Who this page is for

This page is for application developers and backend API owners who already have
or are about to receive a SigID application configuration.

By the end of this page, you should know how to:

- run Authorization Code with PKCE without leaking secrets
- choose the right grant type for your app
- exchange authorization codes with the registered client auth method
- validate access tokens before serving protected resources
- use tenant-scoped subjects instead of email addresses as user keys
- request scopes and refresh tokens deliberately
- troubleshoot OAuth error responses without guessing

## On This Page

- [Before you start](#before-you-start)
- [Choose the right OAuth path](#choose-the-right-oauth-path)
- [Authorization Code with PKCE](#authorization-code-with-pkce)
- [Token validation](#token-validation)
- [Tenant-scoped subjects and claims](#tenant-scoped-subjects-and-claims)
- [OAuth and OIDC endpoint reference](#oauth-and-oidc-endpoint-reference)
- [Choose a grant type](#choose-a-grant-type)
- [Scopes and consent](#scopes-and-consent)
- [Refresh tokens and revocation](#refresh-tokens-and-revocation)
- [Device authorization, CIBA, and token exchange](#device-authorization-ciba-and-token-exchange)
- [Common integration mistakes](#common-integration-mistakes)

## Before you start

Get these values from your tenant administrator or platform team. They should
match the handoff packet from
[Launch Your Workspace](../business/launch-workspace.md).

| Value | Used for |
|---|---|
| `SIGID_ISSUER_URL` | Discovery, hosted auth redirects, token exchange, JWKS, and `/userinfo` |
| Tenant ID or tenant slug | Support, logs, tenant isolation, and environment troubleshooting |
| `SIGID_CLIENT_ID` | Authorization requests and public-client token exchange |
| `SIGID_REDIRECT_URI` | Callback allowlist and authorization-code token exchange |
| `SIGID_SCOPES` | Consent, UserInfo claims, and API authorization checks |
| Token endpoint auth method | Whether token exchange uses `none`, `client_secret_post`, `client_secret_basic`, or configured `private_key_jwt` |
| Client secret | Confidential backend clients only; never expose this to browsers or mobile apps |
| API audience | Required when your backend validates access tokens for an API |
| Refresh-token policy | Whether the app may request `offline_access` and use the `refresh_token` grant |
| Test user | A non-admin user that can exercise the expected tenant, organization, role, and policy state |

Keep development, staging, and production clients separate. Redirect URIs,
secrets, scopes, and audiences should not drift between environments by
accident.

## Choose the right OAuth path

| Use case | Recommended path | Notes |
|---|---|---|
| Web, mobile, desktop, or browser login | Authorization Code with PKCE | Default path for interactive user login |
| Web app with a trusted backend | Authorization Code with PKCE plus a confidential client auth method | Keep the client secret server-side only |
| Browser-only, mobile, desktop, or CLI app without a trusted secret store | Authorization Code with PKCE as a public client | Use token endpoint auth `none`; still use PKCE |
| Backend service calling another backend | Client Credentials | Confidential clients only; no refresh token is issued |
| CLI, TV, terminal, or input-constrained device | Device Authorization | Requires the device-code grant and platform support |
| Agent acting for a user | Token Exchange | Validate `subject_type`, `act`, audience, scopes, and delegation policy |
| Backchannel approval without browser redirect | CIBA | Use only when the app has a real backchannel-auth use case |
| Long-lived user session | Authorization Code with PKCE plus `offline_access` | Request only when the app needs refresh tokens |

Most applications should start with Authorization Code with PKCE and add other
grants only when there is a concrete product workflow.

## Authorization Code with PKCE

<!-- agent:action Run the PKCE login flow
Implement the five numbered steps in order: create `state` and a PKCE `code_verifier`, redirect to `/oauth/authorize` with the S256 `code_challenge`, handle the callback, exchange the code at `/oauth/token`, then create a validated server-side session.
- send only the derived `code_challenge`, never the verifier, in the authorization request
- verify `state` on the callback and exchange each code exactly once
- use the exact registered redirect URI and the registered token endpoint auth method
- do not log codes, tokens, or secrets at any step
-->

Authorization Code with PKCE is the recommended flow for user login. SigID signs
the user in on hosted auth, redirects back with an authorization code, and your
app exchanges that code at `/oauth/token`.

### 1. Create state and PKCE values

Create a random `state` value for CSRF protection and a random PKCE
`code_verifier`. Send only the derived `code_challenge` in the authorization
request. Store the original `state` and `code_verifier` in a server-side session
or another storage location that is bound to the browser interaction.

```javascript
import crypto from "node:crypto";

function base64url(bytes) {
  return Buffer.from(bytes).toString("base64url");
}

export function createPkceRequestState() {
  const state = base64url(crypto.randomBytes(32));
  const codeVerifier = base64url(crypto.randomBytes(32));
  const codeChallenge = base64url(
    crypto.createHash("sha256").update(codeVerifier).digest(),
  );

  return { state, codeVerifier, codeChallenge };
}
```

SigID supports the `S256` PKCE method. Do not send the verifier in the
authorization request.

### 2. Redirect to hosted auth

Build an authorization URL on the issuer:

```text
GET /oauth/authorize
  ?response_type=code
  &client_id=CLIENT_ID
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback
  &scope=openid%20profile%20email
  &code_challenge=BASE64URL_SHA256_VERIFIER
  &code_challenge_method=S256
  &state=OPAQUE_CSRF_VALUE
```

Use the exact registered redirect URI. These are different values:

```text
https://app.example.com/auth/callback
https://app.example.com/auth/callback/
http://app.example.com/auth/callback
```

### 3. Handle the callback

Successful callback:

```text
GET https://app.example.com/auth/callback?code=AUTH_CODE&state=OPAQUE_CSRF_VALUE
```

Error callback:

```text
GET https://app.example.com/auth/callback?error=access_denied&error_description=...
```

Callback handler checklist:

- verify the returned `state`
- handle `error` as a user-visible retry path
- exchange each `code` only once
- use the same `redirect_uri` used in the authorization request
- use the original `code_verifier`
- do not log codes, tokens, client secrets, or refresh tokens

### 4. Exchange the authorization code

Use the token endpoint auth method registered on the application. Do not infer
the method from the runtime. Check the application settings or dynamic
registration response and use the exact registered value.

Public PKCE client:

```bash
curl -sS "$SIGID_ISSUER_URL/oauth/token" \
  -H "content-type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "client_id=$SIGID_CLIENT_ID" \
  --data-urlencode "code=$AUTHORIZATION_CODE" \
  --data-urlencode "redirect_uri=$SIGID_REDIRECT_URI" \
  --data-urlencode "code_verifier=$CODE_VERIFIER"
```

Confidential client using `client_secret_post`:

```bash
curl -sS "$SIGID_ISSUER_URL/oauth/token" \
  -H "content-type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "client_id=$SIGID_CLIENT_ID" \
  --data-urlencode "client_secret=$SIGID_CLIENT_SECRET" \
  --data-urlencode "code=$AUTHORIZATION_CODE" \
  --data-urlencode "redirect_uri=$SIGID_REDIRECT_URI" \
  --data-urlencode "code_verifier=$CODE_VERIFIER"
```

Confidential client using `client_secret_basic`:

```bash
curl -sS "$SIGID_ISSUER_URL/oauth/token" \
  -u "$SIGID_CLIENT_ID:$SIGID_CLIENT_SECRET" \
  -H "content-type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=$AUTHORIZATION_CODE" \
  --data-urlencode "redirect_uri=$SIGID_REDIRECT_URI" \
  --data-urlencode "code_verifier=$CODE_VERIFIER"
```

Typical response without refresh-token access:

```json
{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "openid profile email",
  "id_token": "eyJ..."
}
```

For user login flows such as Authorization Code, Device Authorization, and CIBA,
SigID returns a `refresh_token` only when `offline_access` was granted. Delegated
token exchange can issue a new refresh-token family for the actor according to
delegation policy.

### 5. Create your application session

After token exchange, your backend should either validate access tokens on every
API request or create a trusted server-side application session after validation.
Do not authorize protected API requests from frontend login state alone.

## Token validation

<!-- agent:action Validate an access token
Apply every row of the validation checklist before serving a protected request; fail closed if any check cannot run.
- load issuer metadata and JWKS from discovery, then verify the JWT signature with the expected algorithm and key
- require exact `iss`, your API `aud`, valid `exp`/`nbf`, matching `tenant_id`, the route's `scope`, and an allowed `subject_type`
- enforce `act` delegation policy and DPoP `cnf` binding when those claims are present
- never substitute an ID token, UserInfo response, or decoded payload for this validation
-->

Access tokens protect APIs. ID tokens describe the authentication result for the
client, and UserInfo returns consented profile claims. Do not use an ID token,
UserInfo response, or decoded JWT payload as a substitute for access-token
validation.

Use the checklist below as the minimum validation contract for protected APIs.
Every protected API request should fail closed unless these checks pass:

| Check | What to verify |
|---|---|
| Discovery and JWKS | Load issuer metadata from `/.well-known/openid-configuration` and signing keys from the discovered JWKS URI |
| Token type and signature | Verify the JWT signature with the expected algorithm and key; do not only decode the payload |
| Issuer | `iss` matches `SIGID_ISSUER_URL` |
| Audience | `aud` matches your API audience, not some other client or service |
| Lifetime | `exp` and `nbf` allow the token at request time |
| Tenant | `tenant_id` matches the tenant that owns the requested resource |
| Scope | `scope` contains the permission required for the action |
| Subject type | `subject_type` is acceptable for the route, such as `human`, `agent`, `system`, or `anonymous` |
| Delegation | If `act` is present, enforce the delegated actor policy and reduced scopes |
| Sender constraint | If DPoP is used, verify the DPoP proof and `cnf` binding |

A minimal backend authorization pattern is:

1. Read `Authorization: Bearer <access_token>`.
2. Verify the access token with the discovered JWKS and expected issuer.
3. Require your API audience.
4. Match `tenant_id` to the resource tenant before checking scopes.
5. Require the route's scope or policy.
6. Reject unexpected subject types, agent claims, or delegation chains.
7. Build your application principal from validated claims only.

## Tenant-scoped subjects and claims

SigID can present pairwise subject identifiers for tenant-scoped third-party
user or agent tokens. First-party targets, system principals, and some
client-credentials application principals may receive non-pairwise subjects.
Use the validated `sub` claim together with the tenant context as your durable
application user key.

Do not use email as a primary key. Email can change, can be unverified in some
contexts, and may not uniquely identify the authorization subject.

Do not assume the same human has the same `sub` across tenants or unrelated
client sectors. If your product intentionally shares identity across multiple
applications, make that mapping explicit in your own data model.

Important access-token claims:

| Claim | Use |
|---|---|
| `sub` | Tenant-application subject identifier for the caller |
| `tenant_id` | Tenant isolation boundary |
| `client_id` | OAuth application that received the token |
| `aud` | API or resource server audience |
| `scope` | Space-delimited permissions |
| `subject_type` | Caller category, such as human, agent, system, or anonymous |
| `act` | Delegated actor chain for token-exchange flows |
| `org` | Active organization context, when present |
| `cnf` | DPoP confirmation claim, when sender-constrained |

Call `/userinfo` only for consented human OIDC profile claims:

```bash
curl -sS "$SIGID_ISSUER_URL/userinfo" \
  -H "authorization: Bearer $ACCESS_TOKEN"
```

Your API should authorize from validated access-token claims, not from the
UserInfo response.

## OAuth and OIDC endpoint reference

Use discovery instead of hard-coding endpoint URLs:

```bash
curl -sS "$SIGID_ISSUER_URL/.well-known/openid-configuration"
```

Common fields:

```json
{
  "issuer": "https://auth.example.com",
  "authorization_endpoint": "https://auth.example.com/oauth/authorize",
  "token_endpoint": "https://auth.example.com/oauth/token",
  "jwks_uri": "https://auth.example.com/.well-known/jwks.json",
  "userinfo_endpoint": "https://auth.example.com/userinfo"
}
```

Endpoint availability can vary by deployment and application policy. Discovery
metadata and the target deployment's OpenAPI document are the source of truth
for enabled protocol surfaces.

| Endpoint | Use |
|---|---|
| `GET /.well-known/openid-configuration` | Discover OAuth and OIDC metadata for the issuer |
| `GET /.well-known/jwks.json` | Fetch signing keys for JWT verification |
| `GET /oauth/authorize` | Start hosted login, consent, and authorization-code issuance |
| `POST /oauth/token` | Exchange authorization codes, refresh tokens, device codes, CIBA grants, client credentials, and token-exchange requests |
| `GET /userinfo` | Read consented OIDC profile claims for a human user |
| `POST /oauth/revoke` | Revoke refresh tokens or access tokens during logout, disconnect, or compromise handling |
| `POST /oauth/introspect` | Let trusted backends inspect token state when local JWT validation is not enough |
| `POST /oauth/par` | Push large or sensitive authorization request parameters before redirecting |
| `POST /oauth/device/code` | Start Device Authorization for input-constrained clients |
| `GET /oauth/device/verify` | User-facing device verification page |
| `POST /bc-authorize` | Start CIBA backchannel authentication |
| `GET /api/v1/identity/ciba/requests` | List pending CIBA requests for the authenticated human user |
| `POST /api/v1/identity/ciba/requests/{id}/approve` | Approve a pending CIBA request |
| `POST /api/v1/identity/ciba/requests/{id}/deny` | Deny a pending CIBA request |
| `GET or POST /oauth/end-session` | OIDC RP-initiated logout, when enabled |
| `POST /oauth/register` | Dynamic Client Registration, when enabled |

For the full product surface, see
[Product Reference](product-reference.md#oauth-capabilities).

## Choose a grant type

| Grant | Use it for | Avoid when |
|---|---|---|
| `authorization_code` | Interactive user login with PKCE | The caller is a backend service with no user |
| `refresh_token` | Renewing a user session after `offline_access` was granted | The app only needs a short browser session |
| `client_credentials` | Machine-to-machine backend access | Public clients or user-delegated actions |
| `urn:ietf:params:oauth:grant-type:device_code` | CLI, TV, terminal, and other input-constrained clients | A normal browser redirect is available |
| `urn:ietf:params:oauth:grant-type:token-exchange` | Agent or delegated access after policy checks | You are only logging in a human user |
| `urn:openid:params:grant-type:ciba` | Backchannel user approval | Your app can use a normal redirect flow |

Every grant must be allowed on the application. If a request uses a known grant
that the application does not allow, the token endpoint returns
`unauthorized_client`. If the `grant_type` value itself is unsupported or
malformed, the token endpoint returns `unsupported_grant_type`.

## Scopes and consent

Start with the smallest scope set that proves the login flow. Add broader scopes
only when the user takes a feature-specific action that needs them.

Common scopes:

| Scope | Meaning |
|---|---|
| `openid` | Request OIDC login semantics and an ID token |
| `profile` | Allow profile claims such as name, when available |
| `email` | Allow email claims, when available |
| `offline_access` | Allow refresh-token issuance when policy and grant configuration permit it |
| API scopes such as `projects:read` | Authorize your own protected API actions |

Consent text should describe the user-visible result, not only the internal scope
name. For delegated agent access, explain what the agent can do, whose authority
it uses, and when that access ends.

Advanced authorization parameters:

| Need | Parameter or endpoint |
|---|---|
| Force login | `prompt=login` |
| Force consent | `prompt=consent` |
| Silent check with an existing session | `prompt=none` |
| Require recent authentication | `max_age=300` |
| Request step-up assurance | `acr_values=urn:sigid:aal2` |
| Request specific OIDC claims | `claims` |
| Request rich authorization details | `authorization_details` |
| Push request parameters before redirect | `POST /oauth/par`, then authorize with `request_uri` |

Advanced parameters and endpoints must be enabled by the deployment and allowed
for the application before clients depend on them.

## Refresh tokens and revocation

<!-- agent:action Handle refresh tokens safely
Request `offline_access` only when the app must renew sessions without a browser redirect; user login flows return no `refresh_token` without it.
- store refresh tokens server-side or in platform-secure storage, never in logs or analytics
- persist the newest token when rotation returns one, and treat reuse as a security event
- refresh at `/oauth/token` with `grant_type=refresh_token` using the registered client auth method
- revoke at `/oauth/revoke` on logout, disconnect, account removal, or suspected compromise
-->

Request `offline_access` only when your application needs to renew a user session
without another browser redirect.

Refresh-token rules:

- for user login flows, no `offline_access` scope means no `refresh_token` in the token response
- delegated token exchange may return a refresh token according to delegation policy
- store refresh tokens server-side or in platform-secure storage
- keep refresh tokens out of logs, analytics, screenshots, and support tickets
- persist the newest refresh token if rotation returns one
- do not use refresh tokens for `client_credentials`
- treat refresh-token reuse as a security event
- revoke tokens on logout, disconnect, account removal, or suspected compromise

Refresh with a public client:

```bash
curl -sS "$SIGID_ISSUER_URL/oauth/token" \
  -H "content-type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "client_id=$SIGID_CLIENT_ID" \
  --data-urlencode "refresh_token=$REFRESH_TOKEN"
```

For a confidential client, authenticate with the same token endpoint auth method
registered on the application.

## Device authorization, CIBA, and token exchange

<!-- agent:action Implement a non-redirect grant
Pick the grant that matches the constraint: Device Authorization for input-constrained clients, CIBA for trusted backchannel approval, Token Exchange for delegated or agent access.
- device flow: start at `/oauth/device/code`, show the user code, poll `/oauth/token` at the returned interval, and honor `authorization_pending`, `slow_down`, `access_denied`, and `expired_token`
- CIBA: `POST /bc-authorize` with poll mode and a confidential client, then poll `/oauth/token` with `urn:openid:params:grant-type:ciba` and `auth_req_id`; unknown login hints return decoy successes by design
- token exchange: validate audience, tenant, reduced scopes, `subject_type`, and `act` on the resulting token before allowing tool or API actions
-->

Use Device Authorization for input-constrained clients:

```bash
curl -sS "$SIGID_ISSUER_URL/oauth/device/code" \
  -H "content-type: application/x-www-form-urlencoded" \
  --data-urlencode "client_id=$SIGID_CLIENT_ID" \
  --data-urlencode "scope=openid profile"
```

The client shows the returned user code and polls `/oauth/token` according to
the returned interval. Respect `authorization_pending`, `slow_down`,
`access_denied`, and `expired_token`.

Use CIBA only when the client cannot redirect the user but can start a trusted
backchannel authorization request. SigID supports poll delivery mode with
confidential clients. `POST /bc-authorize` accepts `scope`, `login_hint`,
optional `binding_message`, optional `requested_expiry`, and optional
`authorization_details`/`claims`; it rejects `login_hint_token`,
`id_token_hint`, push/ping notification parameters, CIBA `user_code`, and
CIBA-specific `acr_values`. Unknown or inactive login hints return the same
successful start response as known users, but create a decoy request that never
appears in the user's approval list and never issues tokens. The authenticated
human lists pending requests at `/api/v1/identity/ciba/requests`, approves or
denies one by ID, and the client polls `/oauth/token` with grant type
`urn:openid:params:grant-type:ciba` and `auth_req_id`. Pollers must honor
`authorization_pending`, `slow_down` with `Retry-After`, `access_denied`, and
`expired_token`.

Use Token Exchange for delegated access, especially agent workflows. Do not
treat delegated agent tokens as ordinary human user tokens. Validate audience,
tenant, reduced scopes, `subject_type`, and `act` claims before allowing tool or
API actions. For agent-specific guidance, see
[Agent And MCP Auth](../developers/agents-mcp.md).

## Hosted sign-in methods

Applications should redirect users to SigID hosted auth. Do not reimplement
password, passkey, social login, SSO, MFA, recovery, or consent screens inside
your application unless a product-specific integration explicitly requires it.

Tenant administrators decide which hosted sign-in methods are enabled and how
Enterprise SSO domains route users. Developers should handle OAuth redirects,
callbacks, token exchange, token validation, and application sessions.

## Common integration mistakes

| Mistake | Fix |
|---|---|
| Using `SIGID_AUTH_ORIGIN` in new code | Use `SIGID_ISSUER_URL` |
| Redirect URI mismatch | Copy the exact registered URI, including scheme, host, port, path, and trailing slash |
| State mismatch | Store `state` server-side and reject mismatches |
| PKCE mismatch | Use the original `code_verifier` for token exchange |
| Token auth method mismatch | Use the registered method: `none`, `client_secret_post`, `client_secret_basic`, or configured `private_key_jwt` |
| Expecting `refresh_token` from user login without `offline_access` | Request `offline_access` only when the app needs refresh tokens |
| Application does not allow the grant | Enable the grant or use the correct app; the error is `unauthorized_client` |
| Unsupported `grant_type` value | Fix the literal grant value; the error is `unsupported_grant_type` |
| Accepting any audience | Require your API audience |
| Decoding JWTs without verifying them | Verify signature, issuer, audience, lifetime, and tenant |
| Trusting frontend-only login state | Validate access tokens on the backend or create a server-side session after validation |
| Using email as user ID | Store the tenant-scoped `sub` with tenant context |
| Treating agents as human users | Check `subject_type`, `agent`, and `act` claims |
| Mixing tenant or environment values | Keep issuer, tenant, client ID, redirect URI, and audience from the same environment |
| Ignoring callback errors | Show a retry path and log a request ID without logging secrets |

## Next steps

- [Token validation](#token-validation) for backend authorization requirements
- [Launch Your Workspace](../business/launch-workspace.md) for administrator-owned application setup
- [API And SDK Reference](api-sdk-reference.md) for API and SDK entry points
- [Agent And MCP Auth](../developers/agents-mcp.md) for delegated agent and MCP boundaries
- [Claims And Scopes](claims-and-scopes.md#delegation-claims) for delegated token claims
