# Sign in with AI Passport
URL: /docs/sign-in

OpenID Connect sign-in that can bring the user's approved memory along.

***

title: Sign in with AI Passport
description: OpenID Connect sign-in that can bring the user's approved memory along.
------------------------------------------------------------------------------------

Let people sign in to your app with their AI Passport, the way they sign in
with Google or Apple. It is standard OpenID Connect, so any OIDC client
library works without a custom integration. The difference is what arrives
with the identity: if the user allows it, the same token reads the memory they
have approved. Your app knows who they are and what they care about on the
first screen.

Sign-in is generally available, with automatic admission for host-consistent
metadata-document clients. The contract changes only with notice on the changelog.

Users can arrive with approved context when they grant the `memory` scope.
Your app can start with that context instead of a cold start or twenty
questions.

| Fact         | Value                                           |
| ------------ | ----------------------------------------------- |
| Issuer       | `https://passport.ego.ist`                      |
| Protocol     | OpenID Connect on OAuth 2.1                     |
| Client type  | Public; PKCE S256 required for redirect sign-in |
| ID token     | RS256, valid 1 hour                             |
| Scopes       | `openid`, `profile`, `email`, `memory`          |
| Access token | 1 hour, refresh rotates                         |

## Drop-in button and SDK

Install the official package for the sign-in control and protocol helper.

```bash
npm install ai-passport-signin
```

**Render the browser button and point it at your server start route.**

```html
<script type="module">
  import "ai-passport-signin";
</script>

<ai-passport-button href="/auth/ai-passport/start"></ai-passport-button>
```

Importing the package defines the `ai-passport-button` element. It accepts
`href`, `theme` (`light` or `dark`), `label` (`signin` or `continue`), and
`full-bleed`. Server-rendered pages can use `buttonHTML(...)` from the same
entry point instead.

**Render the React button from the same start route.**

```jsx
import { AIPassportButton } from "ai-passport-signin/react";

export function SignInOptions() {
  return <AIPassportButton href="/auth/ai-passport/start" />;
}
```

**Create server-only begin and callback handlers.**

```js
import { createPassportSignIn } from "ai-passport-signin/server";

const passportSignIn = createPassportSignIn({
  issuer: "https://passport.ego.ist",
  clientId: "https://acme.example/ai-passport-client.json",
  redirectUri: "https://acme.example/auth/ai-passport/callback",
  scopes: ["openid", "profile", "email"],
});

export async function beginSignIn(session) {
  const { url, state } = await passportSignIn.begin();
  session.aiPassportSignIn = state;
  return Response.redirect(url);
}

export async function completeSignIn(request, session) {
  const callback = new URL(request.url);
  const result = await passportSignIn.complete({
    code: callback.searchParams.get("code"),
    state: callback.searchParams.get("state"),
    iss: callback.searchParams.get("iss"),
    storedState: session.aiPassportSignIn,
  });
  delete session.aiPassportSignIn;
  return result;
}
```

Store `state` only in the server-side session. The helper verifies PKCE,
RFC 9207 `iss`, the RS256 ID token, the nonce, and the `at_hash` binding to the
returned access token. Add `memory` and its MCP resource only when your app needs
recall. See the [Brand guidelines](/docs/brand) for the required labels and visual
treatment.

## What it is

AI Passport runs an OAuth 2.1 authorization server with an OpenID Connect
layer on top. Your app is a relying party. It sends the user to AI Passport,
the user authenticates and approves the request, and your app receives an
access token plus a signed ID token that names the user.

An AI Passport identity is not separable from the memory behind it, so every
identity assertion also carries a `passport` claim. The claim names the memory
endpoint and says whether this token may read it. Signing a user in and
reading their memory remain two decisions. The `memory` scope is what connects
them, and the user grants it on the same consent screen.

## Before you build

CIMD admission is automatic when every non-loopback redirect host equals the
document host or is a strict subdomain. Loopback redirect URIs are exempt.
Wildcards, non-loopback IP literals, HTTP on non-loopback hosts, and mixed
host trees do not qualify for automatic admission. Clients outside the
host-consistent case need manual review; invalid redirect URIs must be corrected.
Admission confirms domain control only, not an endorsement or a memory grant.

Dynamic Client Registration lets any client register a name, so a self-chosen
name proves nothing. Authorization codes only go to registered redirect URIs.
AI Passport refuses a client whose redirect hosts it does not admit. The user
never sees a consent screen for that client.

Send non-host-consistent CIMD hosts and DCR admission requests through the
[developer contact form](/developer#request-access) or at
[support@ego.ist](mailto:support@ego.ist). Until a host is admitted,
`/authorize` redirects back with `error=unauthorized_client`. You can still
build the whole flow against a local backend, where the gate is off.

Two more things to know before your first test. In-flow Passport creation at
the sign-in gate is controlled by an operator flag. Where that rollout is
active, a new email can create an AI Passport and acknowledges the terms before
returning to your app; full onboarding continues on my.ego.ist afterward. Where
it is off, the gate signs in existing Passports only. The identity your app
receives is the account, not any marketing profile around it.

Use an approved sign-in label and give it parity with other providers. See the
[Brand guidelines](/docs/brand) for exact wording, size, spacing, and variants.

### iPhone and iPad apps

An iPhone or iPad app that offers Continue with AI Passport for its primary
account must also offer native Sign in with Apple at equal prominence. Do not
ship Continue with AI Passport as the only identity option on those platforms,
and do not put the Apple button inside the hosted Passport page.

[App Review Guideline 4.8](https://developer.apple.com/app-store/review/guidelines/#login-services)
requires an app with third-party login to add another equivalent option that
limits identity data, supports a private email address, and does not collect app
interactions for advertising without consent. AI Passport does not currently
provide all three properties. This is product integration guidance, not legal
advice. See [Exchange a native Apple assertion](/docs/native-sign-in#exchange-a-native-apple-assertion)
for the ticket-bound Apple exchange, prior-consent handling, PKCE redemption,
private relay behavior, token custody, and failure handling.

## Endpoints

All paths are relative to the issuer, `https://passport.ego.ist`. Discovery is
the only URL worth hardcoding.

| Endpoint                                | Purpose                                                                                                        |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `GET /.well-known/openid-configuration` | Discovery document. Read it at startup and take every other URL from it rather than hardcoding paths.          |
| `GET /.well-known/jwks.json`            | RS256 public signing keys for ID token verification, keyed by `kid`.                                           |
| `POST /register`                        | Deprecated Dynamic Client Registration (RFC 7591). Returns a `client_id`. No client secret.                    |
| `GET /authorize`                        | Authorization request. Sends the user through the sign-in gate and the consent screen.                         |
| `POST /oauth/device_authorization`      | Begin owner-approved pairing for an agent without a public host.                                               |
| `POST /connect-code/redeem`             | Redeem an owner-minted one-time connect code.                                                                  |
| `POST /token`                           | Authorization code, device code, and refresh token grants. Returns an `id_token` when `openid` was granted.    |
| `POST /oauth/token-info`                | Self-introspection for an access token. Returns its server-derived client and owner bindings.                  |
| `GET\|POST /userinfo`                   | Identity claims for an access token that carries the `openid` scope.                                           |
| `POST /revoke`                          | Token revocation (RFC 7009). Use it when a user signs out of your app.                                         |
| `/mcp`                                  | The memory resource, over streamable HTTP MCP, reachable with the same access token when `memory` was granted. |
| `POST /memory/v1/recall`                | User-present structured memory recall for an admitted client whose token carries `memory`.                     |

Discovery advertises `response_types_supported: ["code"]`,
`subject_types_supported: ["public"]`,
`id_token_signing_alg_values_supported: ["RS256"]`,
`token_endpoint_auth_methods_supported: ["none"]`, and
`code_challenge_methods_supported: ["S256"]`. It also advertises
`authorization_response_iss_parameter_supported: true`. There is no implicit flow, no
hybrid flow, and no client secret.

Discovery also advertises
`client_id_metadata_document_supported: true`.

## Structured memory recall

An admitted native app can request machine-readable normal memory while the
user is present. Send an OAuth access token with the `memory` scope to
`POST /memory/v1/recall`. The token decides the Passport owner and OAuth
client. Do not put either identifier in the body.

```bash
curl -X POST https://passport.ego.ist/memory/v1/recall \
  -H 'authorization: Bearer YOUR_ACCESS_TOKEN' \
  -H 'content-type: application/json' \
  -d '{
    "categories": ["preference", "fact"],
    "purpose": "recall",
    "query": "travel seating",
    "limit": 10,
    "read_id": "trip-results-screen-01"
  }'
```

Generate one opaque `read_id` for a logical screen read and reuse it only when
retrying that same read. It is required and may contain 1 to 128 characters.
The query, category set, and per-category limit must stay identical. A mismatch
returns `read_id_conflict`. After rows are served, a matching retry rehydrates
only their recorded memory IDs and never runs a new semantic search. Deletions
may make the retry a subset of the first response; newly matching memories are
never added. The retry binding lasts 30 days from the first logical read. Use a
new `read_id` after that horizon.

Replay rechecks the recorded snapshot, pass status and expiry, session-token
binding, and account deletion after exact-ID hydration. A revocation that lands
during hydration refuses the replay. If the owner's storage is sealed, AI
Passport may use the pass and exact category recorded with the snapshot to
mint a short pass-bound lease. That lease can hydrate only the recorded IDs; it
cannot run a new search or widen categories.

`limit` applies independently to every declared category. One category never
uses another category's result budget. `empty` means a healthy semantic search
found no candidate, not that another category exhausted a shared limit.

When a category has no pass, its entry has `outcome: approval_required` and a
short-lived `approval_url`. Open that exact URL for the user. It locates the
request in the AI Passport owner surface, but it does not authenticate the
user or approve anything. Retry the same `read_id` after the owner decides.
A retry may return another URL, and every URL you received remains valid until
its own `approval_expires_at` value.

```json
{
  "outcome": "partial",
  "categories": [
    {
      "category": "preference",
      "outcome": "results",
      "rows": [
        {
          "memory_id": "0f2b6c1e-6c1a-4f2e-9f4a-1a2b3c4d5e6f",
          "content": "Prefers an aisle seat on long flights.",
          "source": "owner",
          "created_at": "2026-08-01T09:15:00.000Z",
          "occurred_at": "2026-08-01T09:15:00.000Z",
          "category": "preference",
          "client_id": null,
          "evidence_basis": null,
          "record_kind": null,
          "verified_issuer": null,
          "verified_at": null
        }
      ]
    },
    {
      "category": "fact",
      "outcome": "approval_required",
      "approval_url": "https://passport.ego.ist/memory/approve?ticket=...",
      "approval_expires_at": "2026-09-01T12:15:00.000Z"
    }
  ]
}
```

Top-level outcomes are `ok`, `approval_required`, `locked`, `unavailable`,
`account_unavailable`, `rate_limited`, and `partial`. Category outcomes are
`results`, `empty`, `approval_required`, `declined`, `expired`, `locked`, and
`unavailable`. Treat `locked` and `unavailable` as retryable distinct states.
Top-level `approval_required` takes priority over `locked`. A locked category
may carry `unlock_url` for an owner who must unlock once. Temporary scoped
custody failures return `unavailable`; retry in a few seconds without asking
the owner to unlock the whole vault. Neither state means the user has no preferences. An empty semantic match does not
spend a one-time pass.

The only supported purposes are `recall` and separately enabled
`personalize`. They require different passes. This endpoint never returns
protected memory, live connector output, or partner workspace content, and
its approval grants none of those permissions.

**Fetch discovery and use the returned endpoint URLs.**

```bash
curl https://passport.ego.ist/.well-known/openid-configuration
```

**The discovery response describes the supported OpenID Connect surface.**

```json
{
  "issuer": "https://passport.ego.ist",
  "service_documentation": "https://ego.ist/docs/sign-in",
  "op_policy_uri": "https://ego.ist/privacy-policy/",
  "op_tos_uri": "https://ego.ist/terms-of-use/",
  "authorization_endpoint": "https://passport.ego.ist/authorize",
  "device_authorization_endpoint": "https://passport.ego.ist/oauth/device_authorization",
  "token_endpoint": "https://passport.ego.ist/token",
  "introspection_endpoint": "https://passport.ego.ist/oauth/token-info",
  "userinfo_endpoint": "https://passport.ego.ist/userinfo",
  "jwks_uri": "https://passport.ego.ist/.well-known/jwks.json",
  "registration_endpoint": "https://passport.ego.ist/register",
  "revocation_endpoint": "https://passport.ego.ist/revoke",
  "scopes_supported": ["openid", "profile", "email", "memory"],
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "grant_types_supported": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "introspection_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported": ["S256"],
  "claims_supported": [
    "sub", "iss", "aud", "exp", "iat", "nonce", "at_hash", "email",
    "email_verified", "name", "picture", "passport"
  ]
}
```

`passport` is a structured claim containing `issuer`, `mcp_url`, and
`memory_access`. With granted `memory` and a completed lookup, it can also
contain `memory_locked` and `connectors`; these are nested fields, not
separate top-level identity claims.

**Fetch the current public signing keys.**

```bash
curl https://passport.ego.ist/.well-known/jwks.json
```

**Select the RSA key whose `kid` matches the ID token header.**

```json
{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "pSMZw_U8C_VlFp6tMHtl7V-B9GFsThIEZm9nQTG0wIQ",
      "n": "tyfkCZcvnKhLJrj-qOVxxhCrJJPoyMWl2AD8rJeqZz12pD34GOZI4fetP_ZpIfUo9NWC7RUZlUI1F2hCyiszNRuBdxQugZ3NAEliB9WDkDtbbZ6WGTwg8e2yotCq3ns-TqGel8ltlGqrd6HbH2cp9Fdj9Q7rI_5TJqqga1QcAXcN0Jg54-hKTeu7ZX6t6AhUgFkzkn2ylOhujPznSzRKfRcJ0QpdE_-8O8U_PnXwx6PGbnaWFCztVMLxzyUBXwaErqPyxhGWsjT96DsRW8muYhZEW_QnoNQeDaK5dlmxas7BljRezIcXn_WJNyfXbWok4Gbx91OE6znXypTnt5vHLQ",
      "e": "AQAB"
    }
  ]
}
```

## The flow

1. Your app reads discovery and uses its hosted Client Identifier URL, or
   registers once with the deprecated DCR endpoint.
2. Your app sends the user to `/authorize` with PKCE, `state`, and `nonce`.
3. The user signs in at the AI Passport gate. If they are already signed in to
   their Passport, the gate hands off to a one-click confirmation instead of
   asking for a credential again.
4. The consent screen names your app, the account being signed in, and each
   thing you asked for in plain language. The user allows or denies.
5. On approval, the browser returns to your `redirect_uri` with a code and
   your `state`. The code is single use and expires in 5 minutes.
6. Your server exchanges the code at `/token` for an access token, a refresh
   token, and an ID token. You verify the ID token, and the user is signed in.

<Callout title="Server side only">
  The token exchange, ID token verification, and any memory read belong on
  your server. The browser should only ever carry the redirect.
</Callout>

## Register your app

CIMD is the preferred registration path. Host a Client
Identifier Metadata Document at an HTTPS URL on your app's own host. Use that
URL as the `client_id` in every authorization request.

The document shall contain `client_id`, `client_name`, `redirect_uris`, and
`token_endpoint_auth_method: "none"`. Its `client_id` string shall exactly
equal the URL that serves the document. A trailing slash, default port, or
other spelling change makes a different client id.

The metadata document must list each purpose scope in `scope` before the app
can request it. This declaration sets a maximum. It does not grant a pass or
bypass the owner's approval.

**Serve a client metadata document at its exact client id URL.**

```js
import { clientMetadataDocument } from "ai-passport-signin/server";

export function GET() {
  return Response.json(clientMetadataDocument({
    clientId: "https://acme.example/ai-passport-client.json",
    clientName: "Acme Notes",
    redirectUris: ["https://acme.example/callback"],
    scope: ["openid", "profile", "memory", "connector:reads"],
  }));
}
```

List `booking:actions` the same way when the app uses the booking-action flow.
The deployment must also offer the purpose scope before authorization can
request it.

Every non-loopback redirect host must equal the document host or be its strict
subdomain for automatic admission. Loopback redirect URIs are exempt. A valid
document outside this host relationship needs manual review and receives no
authorization screen until admitted.

### Deprecated but supported: Dynamic Client Registration

DCR remains deprecated and compatible. DCR clients still need manual admission.
Use `registerClient(...)` from `ai-passport-signin/server` or `POST /register`.
Each DCR registration is a public PKCE client. A `client_secret` is ignored and
none is returned. Registering again creates a new client id.

**Register one public client for your redirect URI.**

```bash
curl -X POST https://passport.ego.ist/register \
  -H 'content-type: application/json' \
  -d '{
    "client_name": "Acme Notes",
    "redirect_uris": ["https://acme.example/callback"],
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "token_endpoint_auth_method": "none"
  }'
```

**The registration response returns a public client id and no secret.**

```json
{
  "client_name": "Acme Notes",
  "redirect_uris": ["https://acme.example/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "client_id": "Q7x9kM2vP5sR8nT1yL4cBw",
  "client_id_issued_at": 1786730400
}
```

The response contains your `client_id` and the registration echoed back.
Redirect URIs are matched exactly at authorization time, with one exception
from RFC 8252: a loopback redirect may change its port between registration
and use. Register every environment you use, including local development.

## Send the user to authorize

| Parameter               | Presence               | Notes                                                                                                                                                                                 |
| ----------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `response_type`         | Required               | `code`                                                                                                                                                                                |
| `client_id`             | Required               | Your hosted Client Identifier URL, or the id returned by deprecated DCR.                                                                                                              |
| `redirect_uri`          | Required               | Must exactly match a URI you registered.                                                                                                                                              |
| `scope`                 | Required               | Space separated. Include `openid`, or you get a plain OAuth grant with no identity assertion. An omitted or empty value returns `invalid_scope`.                                      |
| `state`                 | Required in practice   | Your CSRF value. It is echoed back on both success and failure.                                                                                                                       |
| `code_challenge`        | Required               | Base64url SHA-256 of your PKCE verifier.                                                                                                                                              |
| `code_challenge_method` | Required               | `S256`. The plain method is not offered.                                                                                                                                              |
| `nonce`                 | Recommended            | Echoed into the ID token so you can bind the token to this request. Send it and check it.                                                                                             |
| `login_hint`            | Optional               | An email address to prefill on the hosted sign-in form. It never skips authentication.                                                                                                |
| `prompt`                | Optional               | Send `create` to request account-creation framing where the rollout is active. It never skips authentication; an existing Passport signs in normally. It is safely ignored elsewhere. |
| `resource`              | Required with `memory` | Must be `https://passport.ego.ist/mcp`. A missing or different target returns `invalid_target`.                                                                                       |

**Send the browser to this authorization URL.**

```text
https://passport.ego.ist/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Facme.example%2Fcallback
  &scope=openid%20profile%20email%20memory
  &resource=https%3A%2F%2Fpassport.ego.ist%2Fmcp
  &state=RANDOM_STATE
  &nonce=RANDOM_NONCE
  &code_challenge=BASE64URL_SHA256_OF_VERIFIER
  &code_challenge_method=S256
```

A request without `openid` is not a sign-in. It is treated as a plain OAuth
grant, it does not reach the consent screen, and no ID token is issued. Every
authorization request names its scopes explicitly. A request with no scope
returns `invalid_scope`, including on the plain OAuth leg.

Every authorization response includes `iss=https://passport.ego.ist`, on
success and error. Check it before accepting the code or error, alongside
`state`, to prevent authorization-server mix-up.

The user has 30 minutes to finish at the gate before the request expires. If
they take longer, start again from `/authorize`.

## Exchange the code

**Exchange the code from your server with its PKCE verifier.**

```bash
curl -X POST https://passport.ego.ist/token \
  -H 'content-type: application/x-www-form-urlencoded' \
  -d grant_type=authorization_code \
  -d code=THE_CODE \
  -d client_id=YOUR_CLIENT_ID \
  -d redirect_uri=https://acme.example/callback \
  -d code_verifier=YOUR_PKCE_VERIFIER
```

**A successful exchange returns the granted scopes and three tokens.**

```json
{
  "access_token": "jR8mP2xV5kN9sT1yL4cB7wF0aH6eQ3uD8iG2oZ5vKsM",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "cT4nK7sR1xV9mQ2pL6wH0eF5aD8yJ3uB7iZ1oG4kXsE",
  "scope": "openid profile email memory",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InBTTVp3X1U4Q19WbEZwNnRNSHRsN1YtQjlHRnNUaElFWm05blFURzB3SVEifQ.eyJpc3MiOiJodHRwczovL3Bhc3Nwb3J0LmVnby5pc3QifQ.SIGNATURE"
}
```

Read the returned `scope` rather than assuming you got what you asked for.
The user can be signed in to your app while having declined the memory scope,
and your app has to work in that state.

## Verify the ID token

The ID token is an RS256 JWT. Verify it before you trust a single claim in
it. Any OIDC library does this for you. If you verify by hand, the checks
are:

* Fetch `/.well-known/jwks.json` and pick the key whose `kid` matches the
  token header. During a signing key rotation the JWKS carries the retired
  public key alongside the current one, so select by `kid` instead of taking
  the first key, and refetch when a `kid` is unknown.
* Verify the signature, then check `iss` equals the issuer, `aud` contains
  your `client_id`, `exp` is in the future, and `nonce` matches the one you
  sent.
* Compute the left-most 128 bits of SHA-256 over the ASCII access token,
  encode them as base64url without padding, and require that value to equal
  `at_hash`.

**Verify the signature and required claims before you create a session.**

```js
const [headerPart, payloadPart, signaturePart] = idToken.split(".");
const header = JSON.parse(Buffer.from(headerPart, "base64url"));
const claims = JSON.parse(Buffer.from(payloadPart, "base64url"));
const { keys } = await fetch(discovery.jwks_uri).then((response) => response.json());
const jwk = keys.find((key) => key.kid === header.kid);
if (!jwk) throw new Error("Unknown ID token signing key");

const publicKey = crypto.createPublicKey({ key: jwk, format: "jwk" });
const signed = Buffer.from(`${headerPart}.${payloadPart}`);
const signature = Buffer.from(signaturePart, "base64url");
if (!crypto.verify("sha256", signed, publicKey, signature)) throw new Error("Bad signature");
if (claims.iss !== discovery.issuer) throw new Error("Issuer mismatch");
if (![claims.aud].flat().includes(clientId)) throw new Error("Audience mismatch");
if (claims.exp < Math.floor(Date.now() / 1000)) throw new Error("ID token expired");
if (claims.nonce !== expectedNonce) throw new Error("Nonce mismatch");
const expectedAtHash = crypto.createHash("sha256")
  .update(accessToken, "ascii")
  .digest()
  .subarray(0, 16)
  .toString("base64url");
if (claims.at_hash !== expectedAtHash) throw new Error("Access token mismatch");
```

**The verified payload contains identity claims and the Passport resource.**

```json
{
  "iss": "https://passport.ego.ist",
  "aud": "YOUR_CLIENT_ID",
  "iat": 1786730400,
  "exp": 1786734000,
  "nonce": "RANDOM_NONCE",
  "at_hash": "ACCESS_TOKEN_HASH",
  "sub": "9f1c2e6a-0ef8-4f31-8a34-1e6f937dd4ce",
  "email": "ada@example.com",
  "email_verified": true,
  "name": "Ada Lovelace",
  "picture": "https://images.acme.example/ada.png",
  "passport": {
    "issuer": "https://passport.ego.ist",
    "mcp_url": "https://passport.ego.ist/mcp",
    "memory_access": true,
    "memory_locked": false,
    "connectors": ["google-calendar"]
  }
}
```

`sub` is the stable account id and the only safe key for your user records.
The subject type is public, so every app sees the same `sub` for a given
person. Email addresses change. Do not key on them.

`passport.memory_locked` and `passport.connectors` appear only when `memory`
was granted and the lookup completed in time. `memory_locked: true` means the
owner must unlock once before any app can read. A sealed vault with automatic
pass-scoped access is not locked in this sense. `connectors` lists linked
connector slugs, not permission to read them. Missing fields mean unknown, not
false or an empty list.

Identity claims appear only when their scope was granted, and only when the
account has them. Treat `name`, `picture`, and `email` as optional in your
data model.

## Scopes and consent

Four scopes exist, and there is no wildcard. The right column is the sentence
the user reads on the consent screen, which is worth knowing when you decide
what to ask for.

| Scope     | Releases                                                                           | Shown to the user as                  |
| --------- | ---------------------------------------------------------------------------------- | ------------------------------------- |
| `openid`  | Turns the request into a sign-in. Releases `sub`, and makes `/userinfo` available. | Confirm your identity                 |
| `profile` | Releases `name` and `picture`, when the account has them set.                      | See your name and profile picture     |
| `email`   | Releases `email` and `email_verified`.                                             | See your email address                |
| `memory`  | Lets the same access token read memory over MCP.                                   | Read your AI Passport portable memory |

One nuance: the `openid` sentence appears only when it is the whole request.
When other scopes come along, the consent screen conveys the sign-in by
naming the account being signed in instead.

Ask for what your first screen actually needs. A sign-in that requests
`openid profile email` is a smaller decision for the user than one that adds
`memory`. You can send them through `/authorize` again later with the wider
scope when the feature that needs it appears.

## UserInfo

`/userinfo` returns the same scoped identity and `passport` claims. It omits
ID token fields such as `iss`, `aud`, `iat`, `exp`, and `nonce`. Use UserInfo
to refresh a profile later. Do not use it instead of ID token verification.
The same `memory`-scope and completed-lookup rule governs `memory_locked` and
`connectors` here. Either optional field may be absent.

**Fetch the current profile with the access token.**

```bash
curl https://passport.ego.ist/userinfo \
  -H 'authorization: Bearer YOUR_ACCESS_TOKEN'
```

**UserInfo releases only claims covered by the granted scopes.**

```json
{
  "sub": "9f1c2e6a-0ef8-4f31-8a34-1e6f937dd4ce",
  "email": "ada@example.com",
  "email_verified": true,
  "name": "Ada Lovelace",
  "picture": "https://images.acme.example/ada.png",
  "passport": {
    "issuer": "https://passport.ego.ist",
    "mcp_url": "https://passport.ego.ist/mcp",
    "memory_access": true,
    "memory_locked": false,
    "connectors": ["google-calendar"]
  }
}
```

## Prove a token belongs to your client

The initial code exchange binds its access token to the signed ID token through
`at_hash`. Verify that claim before using the pair. This check is local and
requires no network request beyond the JWKS lookup used for signature
verification.

For a refreshed access token, or after the retained ID token expires, call the
discovered self-introspection endpoint with the access token as its bearer.
Public PKCE clients have no client secret, so the credential authenticates
itself. An optional form or JSON `token` parameter is accepted only when it is
byte-for-byte equal to the bearer.

```bash
curl -X POST https://passport.ego.ist/oauth/token-info \
  -H 'authorization: Bearer YOUR_ACCESS_TOKEN'
```

```json
{
  "active": true,
  "client_id": "YOUR_CLIENT_ID",
  "sub": "9f1c2e6a-0ef8-4f31-8a34-1e6f937dd4ce",
  "scope": "openid connector:reads",
  "exp": 1786734000,
  "token_type": "Bearer",
  "iss": "https://passport.ego.ist"
}
```

The response also includes `aud` when the access token carries an RFC 8707
resource. Compare `client_id` to your exact client id and `sub` to the Passport
identity already held by your backend. Reject either mismatch, including a
same-owner token issued to another client. Every field is server-derived.
Invalid and expired bearers receive a 401 challenge. This endpoint never
returns `active: false` because the bearer is the credential being described.

## Bring the memory along

The MCP `recall` tool reads live connector data through its `connectors`
parameter as well as governed memory. `POST /memory/v1/recall` reads governed
memory only and never returns live connector data.

When the user grants `memory`, the access token you already hold reads their
Passport over MCP at the `mcp_url` in the `passport` claim. There is no
second handshake and no second credential. Read `passport.memory_access` to
know whether this token can do it.

The supported MCP protocol revision is `2025-11-25` through the official SDK.
We adopt a new revision within one release of SDK support.

### Discover what the token can do

Call `passport_status` first after connecting to MCP with a memory-scoped
bearer. It returns a text summary and `structuredContent` in this shape:

```json
{
  "contract_version": 1,
  "issuer": "https://passport.ego.ist",
  "mcp_url": "https://passport.ego.ist/mcp",
  "client": { "client_id": "YOUR_CLIENT_ID", "name": "Acme Notes", "verified": true },
  "scopes": ["memory"],
  "memory": {
    "state": "sealed_owner_unlock_required",
    "unlock_url": "https://my.ego.ist/memory-lock",
    "categories": ["fact", "preference", "project"],
    "passes_known": true,
    "passes": [
      { "category": "fact", "purpose": "recall", "duration": "permanent", "expires_at": null }
    ]
  },
  "connectors": {
    "enabled": [
      { "connector": "google-calendar", "label": "Google Calendar", "data_categories": ["calendar.events"] }
    ],
    "linked": ["google-calendar"],
    "linked_known": true,
    "passes_known": true,
    "passes": [
      { "connector": "google-calendar", "data_category": "calendar.events", "duration": "permanent", "expires_at": null }
    ],
    "connect_url": "https://my.ego.ist/connectors"
  },
  "owner_urls": {
    "inbox": "https://my.ego.ist/inbox",
    "connectors": "https://my.ego.ist/connectors",
    "memory_lock": "https://my.ego.ist/memory-lock"
  }
}
```

`client` identifies this exact OAuth client. A caller-chosen agent name is
unverified; a CIMD verification badge confirms domain control only. The
listed passes belong to this app, never to every app the owner has connected.
Each `passes_known` flag describes its own list. False means that lookup
failed; an empty list with true means the app has no active passes of that type.

| `memory.state`                 | Meaning and action                                                                                    |
| ------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `open`                         | No memory lock; category passes still govern reads                                                    |
| `sealed_auto_unlock`           | Sealed vault with automatic scoped access for apps holding a pass                                     |
| `sealed_owner_unlock_required` | Owner must unlock once; `memory.unlock_url` is present and points to `https://my.ego.ist/memory-lock` |
| `unknown`                      | The read failed; retry rather than treating memory as empty                                           |

`connectors.enabled` lists available connectors and exact data categories.
`linked` lists sources the owner has connected. If `linked_known` is false,
the list may be partial, or null if the lookup could not return any result.
Retry to learn about missing sources. Linking a source does not grant this
app a connector pass. Use the supplied owner URLs for the corresponding task.

Read live data with the MCP `recall` tool's `connectors` parameter, using
slugs and categories from status or the current tool schema:

```js
await client.callTool({
  name: "recall",
  arguments: {
    query: "upcoming meetings",
    connectors: [{ connector: "google-calendar", data_category: "calendar.events" }],
  },
});
```

`POST /memory/v1/recall` reads governed memory only. Prefer Passport's linked
sources for live calendar, mail, code, and fitness data instead of asking the
owner to connect the same source through an agent's native connector.

### Read and propose memory

**Call the `recall` MCP tool with the same access token.**

The MCP tool always requests the controlled purpose `recall`, meaning read
memory to answer the user. AI Passport also defines a separately enabled
normal-memory purpose, `personalize`, for admitted relying-app operations that
use approved preferences to personalize visible results. Each purpose needs
its own exact app and category pass. A pass for one never authorizes the other.

```js
// Streamable HTTP MCP client, same bearer token
const transport = new StreamableHTTPClientTransport(new URL(claims.passport.mcp_url), {
  requestInit: { headers: { Authorization: `Bearer ${accessToken}` } },
});
await client.connect(transport);
await client.callTool({ name: "passport_status", arguments: {} });
await client.callTool({
  name: "recall",
  arguments: { query: "", categories: ["preference", "project"], purpose: "recall" },
});
```

**A successful tool call returns formatted memory as text content.**

```json
{
  "content": [
    {
      "type": "text",
      "text": "- Prefers concise onboarding instructions. (via Q7x9kM2vP5sR8nT1yL4cBw · 2026-08-10 · stated by the user)"
    }
  ]
}
```

**A missing category pass returns an owner approval link as normal output.**

```json
{
  "content": [
    {
      "type": "text",
      "text": "- 🔐 preference memory \u2014 this app needs the user's approval for this category. Ask the user to review it at https://my.ego.ist/inbox?request=7b2f9c8e-0c1d-4e95-9d28-dbcf20d5ad16#req-7b2f9c8e-0c1d-4e95-9d28-dbcf20d5ad16"
    }
  ]
}
```

The scope is permission to ask, not permission to read. Every recall names
the memory categories and uses the controlled `recall` purpose. The owner
governs access with passes for one app, one category, and one duration.
Without a matching pass the call comes back with an approval link for the
owner rather than content, so handle that outcome as a normal state and show
the link. An empty result and an unavailable engine are also different
answers: an outage is retryable and must not be presented to the user as an
empty Passport.

Approval notices survive a locked recall and always take priority over unlock
guidance. Show the exact approval link when a category has no pass. If scoped
custody is temporarily unavailable, retry in a few seconds; do not send the
owner to unlock the whole vault. Only a vault whose owner has not completed
the required one-time re-escrow returns an owner unlock link. Passes can be
one-time, session, or permanent, and the owner may configure auto-approval.
Memory passes, protected-memory approvals, and connector passes are separate.

A sign-in without the memory scope cannot read anything at `/mcp`. Anything
your app writes back is a proposal that lands in the owner inbox, not a
memory other apps can see. It stays pending until the owner or their
configured review rule approves it.

**Submit new normal memory as a proposal for owner review.**

```js
await client.callTool({
  name: "remember",
  arguments: {
    content: "Prefers concise onboarding instructions.",
    source: "acme-notes",
    category: "preference",
    evidence_basis: "direct_user_save",
  },
});
```

**The tool confirms that the proposal is not yet cross-app memory.**

```json
{
  "content": [
    {
      "type": "text",
      "text": "Submitted this as a pending memory proposal (id 4d1fd47d-9a6a-49aa-a95b-43c8cf962271). It will be available to other apps only after the owner approves it and grants a category pass."
    }
  ]
}
```

## Device authorization

Personal and headless agents without a public host can use RFC 8628 device
authorization. They do not need a callback route or a public tunnel. Discovery
advertises `device_authorization_endpoint` as
`https://passport.ego.ist/oauth/device_authorization`, and
`grant_types_supported` includes `urn:ietf:params:oauth:grant-type:device_code`.

1. The agent begins a pairing and receives a device code, owner-facing code,
   verification URL, expiry, and polling interval.
2. The agent shows the owner the code and verification URL.
3. The owner signs in on my.ego.ist, checks the agent name and code, and chooses
   Allow or Deny. Every device-paired agent has an unverified warning; the owner must
   confirm that the pairing started from their own agent.
4. The agent polls until the owner decides or the pairing expires, then stores
   the returned token pair securely.

### Begin a pairing

Send form data or JSON to `POST /oauth/device_authorization`. Use `client_name`
for a new agent, or reuse a client id returned by an earlier device pairing.
Every device-paired agent is shown to the owner as unverified.

| Field         | Contract                                                                                                                                                   |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `client_id`   | Optional, accepted only for a client id returned by an earlier device pairing; otherwise send `client_name`                                                |
| `client_name` | Agent's own name, 1 to 64 characters, shown as unverified                                                                                                  |
| `scope`       | Subset of `openid profile email memory messaging`; `messaging` is available when agent messaging is enabled on the deployment. Defaults to `openid memory` |

Purpose scopes `booking:actions` and `connector:reads` are not available
through this grant. Admission and token issuance do not create any pass.

```bash
curl -X POST https://passport.ego.ist/oauth/device_authorization \
  -H 'content-type: application/json' \
  -d '{"client_name":"Hana","scope":"openid memory"}'
```

```json
{
  "device_code": "DEVICE_CODE",
  "user_code": "ABCD-EFGH",
  "verification_uri": "https://my.ego.ist/device",
  "verification_uri_complete": "https://my.ego.ist/device?code=ABCD-EFGH",
  "expires_in": 600,
  "interval": 5,
  "client_id": "YOUR_CLIENT_ID"
}
```

Keep the device code private. Show only the user code and verification URL to
the owner. Retain the given or server-assigned `client_id` for the token request.
The owner-facing code is formatted `XXXX-XXXX` and expires after 10 minutes.

### Poll for approval

Wait at least the returned interval before each request. Send form data to
`POST /token` with the exact client id and device code from this pairing:

```bash
curl -X POST https://passport.ego.ist/token \
  -H 'content-type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code' \
  --data-urlencode 'device_code=DEVICE_CODE' \
  --data-urlencode 'client_id=YOUR_CLIENT_ID'
```

| Error                   | Action                                                           |
| ----------------------- | ---------------------------------------------------------------- |
| `authorization_pending` | Continue polling at the current interval                         |
| `slow_down`             | Add 5 seconds to the interval for this and every subsequent poll |
| `access_denied`         | Stop; a new pairing requires another owner action                |
| `expired_token`         | Stop; offer a new pairing if the owner still wants to connect    |
| `invalid_grant`         | Stop; the code is invalid, consumed, or bound to another client  |

Success returns the normal token response plus `mcp_url`. An `id_token` is
present when `openid` was granted, with `at_hash` and the `passport` claim.
Verify JWKS signature, issuer, audience, expiry, and `at_hash` before trusting
identity claims. This grant has no nonce. Refresh uses the ordinary
`refresh_token` grant at `https://passport.ego.ist/token`; replace both tokens
atomically and honor the returned scope.

Use `createDevicePairing` from `ai-passport-signin/server` for begin, one-shot
polling, or `waitForApproval(session, { signal })`. See the
[personal agent guide](/docs/agents) for the SDK, curl sequence, and prompts.

### Owner-minted connect codes

An owner can mint a one-time code on my.ego.ist under Assistants or the install
step in onboarding. It expires in 10 minutes and works once. Redeem it with
JSON at `https://passport.ego.ist/connect-code/redeem`:

```json
{ "code": "XXXX-XXXX", "app": "other", "client_name": "Hana" }
```

Families are `openclaw`, `hermes`, `opencode`, `muse` (Muse), and `other`.
`other` accepts any agent name and displays it to the owner as unverified.
The response contains `mcp_url`, `access_token`, `token_type`, `expires_in`,
`refresh_token`, `scope`, `client_id`, and `token_url`. The mint response reports
the scope the code will carry. It is `memory messaging`
when the messaging service is mounted, agent messaging is enabled, and the owner
is in its rollout; otherwise it is `memory`.
The server checks the service and owner rollout again at redemption. If either
changes during the code's 10-minute lifetime, the redeemed scope can differ.
An existing memory-only install does not gain messaging; the owner must mint a new
code and the agent must redeem it. The response does not return an OpenID
identity token. Call `passport_status` after connecting.

## Request a travel document disclosure

Travel documents never enter your app's agent context. A non-chat-surface
OAuth client uses `request_disclosure` with one exact HTTPS destination, only
the fields that destination needs, and a controlled `purpose`
(`travel_booking` for a booking handoff, otherwise the default
`directed_disclosure`). The owner reviews the request in AI Passport's browser
approval flow, and Passport delivers the approved subset once. Every delivery
carries a stable `Idempotency-Key` and a short-lived signed
`Passport-Disclosure-Attestation` header that your destination must verify
before it reads the body. Do not treat the approval link or the owner's
approval as proof that delivery succeeded. Wait for the terminal delivered or
delivery-failed result. A delivery the destination never answered stays
pending and is not sent again. The
[directed disclosure guide](/docs/directed-disclosure) has the verification
checklist and the full status contract.

Version 1 supports `document_type`, `document_number`, `issuing_country`,
`nationality`, `surname`, `given_names`, `date_of_birth`, `issue_date`,
`expiry_date`, and the optional `sex` marker. It does not accept a scan, photo,
or MRZ value. Passport omits an optional requested field when the owner has not
stored it, so your HTTPS destination must accept fewer keys than requested.

**Request only the booking fields this destination requires.**

```json
{
  "name": "request_disclosure",
  "arguments": {
    "label": "Passport",
    "destination": "https://booking.example/passport",
    "fields": ["document_number", "surname", "given_names", "expiry_date"],
    "purpose": "travel_booking",
    "reason": "Complete this booking"
  }
}
```

## Refresh, expiry, revocation

* Access tokens last 1 hour. ID tokens carry the same 1 hour lifetime.
* Refresh tokens rotate: every exchange returns a new refresh token and
  retires the one you sent, so store the new one atomically.
* Before refresh, persist the old token and a fresh rotation id matching
  `^[A-Za-z0-9._:-]{16,256}$`. Send it as `Passport-Rotation-Id`. A retry with
  the same token and header within five minutes returns the exact prior
  response, even after an IP change. Clients without the header keep the
  same-client-IP fallback.
* A replay after five minutes revokes only that token family. Start a new
  authorization after `invalid_grant`.
* If code exchange commits but its response is lost, use the
  [grant recovery flow](/docs/native-sign-in#recover-a-lost-token-response).
* A refresh returns no new ID token. The identity assertion is made once, at
  sign-in. Call `/oauth/token-info` for every refreshed access token and compare
  its exact `client_id` and `sub`, or reauthorize through `/authorize`. Use
  `/userinfo` when you need current profile claims.
* A refresh can narrow scope but never widen it. Asking for a scope the grant
  does not carry fails with `invalid_scope`.
* Revoke on sign-out: `POST /revoke` with `token` and an optional
  `token_type_hint`. Revoking a live or retired refresh token closes its full
  server-side family, including access-token and refresh-token successors. Per
  RFC 7009 it answers success for unknown tokens too, so it is never an oracle
  for whether a token was valid.

**Refresh the token pair with the current refresh token.**

| Retry                                  | Result within five minutes                      |
| -------------------------------------- | ----------------------------------------------- |
| Same old token and same rotation id    | The original response is returned.              |
| Same old token and another rotation id | `invalid_grant`; the successor stays protected. |
| No rotation header, same IP            | The original response is returned.              |
| No rotation header, another IP         | `invalid_grant`; the successor stays protected. |

```bash
curl -X POST https://passport.ego.ist/token \
  -H 'Passport-Rotation-Id: refresh-attempt-0001' \
  -H 'content-type: application/x-www-form-urlencoded' \
  -d grant_type=refresh_token \
  -d refresh_token=YOUR_REFRESH_TOKEN \
  -d client_id=YOUR_CLIENT_ID \
  -d resource=https://passport.ego.ist/mcp
```

Audience binding is on by default. Access tokens minted before binding may
have no stored resource and remain usable during the compatibility window.
Their next refresh binds the canonical MCP resource, even when the request
omits `resource`. A supplied resource must match the canonical MCP resource.

A historical noncanonical stored resource remains usable. Rotation preserves
that stored value until an operator completes an explicit migration.

**The refresh response returns a new pair and no ID token.**

```json
{
  "access_token": "uY8nC2mR5vK9sD1pL6wF0aH4eJ7tQ3xB8iN2oG5zVcM",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "bT4xN7kP1rV9mQ2sC6wH0eL5aF8yD3uJ7iZ1oG4cKsE",
  "scope": "openid profile email memory"
}
```

**Revoke the refresh token when the user signs out.**

```bash
curl -X POST https://passport.ego.ist/revoke \
  -H 'content-type: application/x-www-form-urlencoded' \
  -d token=YOUR_REFRESH_TOKEN \
  -d token_type_hint=refresh_token \
  -d client_id=YOUR_CLIENT_ID
```

**Revocation returns an empty JSON object, even for an unknown token.**

```json
{}
```

Users can revoke your app at any time from their Passport. An account pending
deletion stops authorizing immediately. Both surface to you as an ordinary
invalid grant or invalid token. Treat those as a signal to start a new
sign-in, not as an error to retry.

Owners can disconnect your app from their Passport at any time, and its tokens
stop working immediately.

## Account lifecycle events

Admitted relying parties can receive signed Security Event Tokens when a
Passport session is disconnected or a Passport account is purged. Registration
is operator-gated. We register one credential-free HTTPS receiver URL and
provision a server-confidential lifecycle audience to your backend. The
lifecycle audience is independent of your OIDC `client_id`.

After sign-in, bind the Passport delegation to your own opaque user identifier.
Call the bind endpoint from your backend with the access token issued for that
user. Passport derives the client and Passport owner from the verified token.
It does not accept either identifier from the JSON body.

```bash
curl -X POST https://passport.ego.ist/oidc/lifecycle/bind \
  -H 'authorization: Bearer USER_ACCESS_TOKEN' \
  -H 'content-type: application/json' \
  -d '{"external_subject":"your-opaque-user-id"}'
```

The subject must be 1 to 128 characters and must not be an email address or a
Passport identifier. Repeating the same binding is safe. Binding a different
subject returns `binding_conflict` until the prior binding has been severed. A
client without an active operator registration receives a typed registration
error.

Each delivery is an HTTPS POST with
`Content-Type: application/secevent+jwt`. Verify all of the following before
using it:

* RS256 signature against
  `https://passport.ego.ist/.well-known/jwks.json`
* protected header `typ` equal to `secevent+jwt`
* `iss` equal to the canonical Passport issuer
* `aud` equal to your separately provisioned lifecycle audience
* a decimal-string `jti` within signed 64-bit range
* `sub_id` equal to `{ "format": "opaque", "id": "..." }`
* exactly one recognized event in `events`

**A session disconnection has one empty CAEP event payload.**

```json
{
  "iss": "https://passport.ego.ist",
  "aud": "your-confidential-lifecycle-audience",
  "iat": 1788206400,
  "jti": "18432",
  "sub_id": { "format": "opaque", "id": "your-opaque-user-id" },
  "events": {
    "https://schemas.openid.net/secevent/caep/event-type/session-revoked": {}
  }
}
```

`session-revoked` means disconnect the Passport session. It does not mean
delete the relying-party account. Passport emits it when the owner disconnects
the app, when a Client Identifier Metadata Document standing transition severs
the client, or when refresh-token reuse kills that token family. Passport does
not emit it when RFC 7009 self-revocation closes a refresh-token family because
the calling client already knows about that revocation.

`account-purged` uses
`https://schemas.openid.net/secevent/risc/event-type/account-purged` with an
empty payload. Fence new writes for the subject, commit your own deletion
contract, and only then acknowledge. Your product's account-deletion path must
remain available when Passport is down.

Return any 2xx response only after the local state transition commits. Store an
idempotent receipt keyed by `jti` before acknowledging because delivery is at
least once. Passport makes no ordering guarantee across event types. A later
event can arrive before an earlier event, so each transition must be safe on
its own.

Transient failures retry with jittered exponential backoff beginning near 30
seconds and capped at one hour. A delivery stops after 12 attempts. Twenty
consecutive exhausted or permanent deliveries disable the receiver. Contact us
to re-enable it after fixing the endpoint. A 3xx is not followed and does not
acknowledge the event.

## Errors

Authorization errors come back on your `redirect_uri` with your `state`.
Token and resource errors are JSON, in the shape RFC 6749 defines. Every JSON
error from an OIDC endpoint includes `request_id`, which matches the
`X-Request-Id` response header.

| HTTP status                               | Error                             | Retry                            | Endpoints                                                                                                | What it means                                                                                                                                  |
| ----------------------------------------- | --------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`, or `302` after redirect validation | `invalid_request`                 | After correction                 | `/authorize`, `/token`, `/revoke`, `/oauth/native/apple`, `/oauth/token-info`, `/oauth/grant-revocation` | A required parameter is missing, malformed, duplicated, or otherwise invalid.                                                                  |
| `400`                                     | `invalid_client`                  | After correction                 | `/authorize`, `/token`, `/revoke`                                                                        | Client authentication failed or the client id is unknown.                                                                                      |
| `302`                                     | `unauthorized_client`             | After correcting client standing | `/authorize` callback                                                                                    | A non-host-consistent CIMD client or DCR client needs manual admission, or the client is suspended or revoked. The user saw no consent screen. |
| `302`                                     | `access_denied`                   | User choice                      | `/authorize` callback                                                                                    | The user denied consent. Start a new authorization only after another user action.                                                             |
| `302` or `400`                            | `invalid_scope`                   | After correction                 | `/authorize`, `/token`                                                                                   | The scope is missing, unsupported, restricted, or wider than the refresh grant.                                                                |
| `302`                                     | `invalid_target`                  | After correction                 | `/authorize` callback                                                                                    | A memory request omitted the MCP resource or named a different resource.                                                                       |
| `400`                                     | `invalid_grant`                   | Reauthorize                      | `/token`, `/oauth/native/apple`                                                                          | The code, refresh grant, pending transaction, or Apple assertion is expired, spent, mismatched, revoked, or outside recovery.                  |
| `400`                                     | `unsupported_grant_type`          | After correction                 | `/token`                                                                                                 | The requested grant type is not supported.                                                                                                     |
| `400`                                     | `invalid_client_metadata`         | After correction                 | `/register`                                                                                              | Dynamic client metadata is malformed or unsupported.                                                                                           |
| `401`                                     | `invalid_token`                   | Refresh or reauthorize           | `/userinfo`, `/oauth/token-info`, `/mcp`                                                                 | The access token is absent, expired, revoked, unbound, or no longer active.                                                                    |
| `403`                                     | `insufficient_scope`              | Reauthorize                      | `/userinfo`, `/mcp`                                                                                      | The valid token lacks `openid` or `memory` for that resource.                                                                                  |
| `403`                                     | `user_delegation_required`        | After sign-in                    | `/oidc/lifecycle/bind`                                                                                   | The access token does not carry a user delegation for this client.                                                                             |
| `400`                                     | `invalid_external_subject`        | After correction                 | `/oidc/lifecycle/bind`                                                                                   | The opaque subject is missing, too long, or malformed.                                                                                         |
| `403`                                     | `lifecycle_registration_required` | After operator admission         | `/oidc/lifecycle/bind`                                                                                   | The token's exact client has no lifecycle receiver registration.                                                                               |
| `403`                                     | `lifecycle_registration_disabled` | After operator review            | `/oidc/lifecycle/bind`                                                                                   | The client's lifecycle receiver registration is disabled.                                                                                      |
| `503`                                     | `lifecycle_rollout_disabled`      | After operator enablement        | `/oidc/lifecycle/bind`                                                                                   | The lifecycle database rollout switch is deliberately paused.                                                                                  |
| `409`                                     | `account_purged`                  | No                               | `/oidc/lifecycle/bind`                                                                                   | The Passport owner has a terminal lifecycle purge fence. A relying party cannot clear it.                                                      |
| `409`                                     | `binding_conflict`                | After disconnect                 | `/oidc/lifecycle/bind`                                                                                   | The client and user already have a different live binding.                                                                                     |
| `409`                                     | `subject_conflict`                | After disconnect                 | `/oidc/lifecycle/bind`                                                                                   | The opaque subject is already bound to a different user.                                                                                       |
| `503`                                     | `lifecycle_unavailable`           | Yes                              | `/oidc/lifecycle/bind`                                                                                   | Binding persistence is temporarily unavailable. Retry with backoff.                                                                            |
| `403`                                     | `account_unavailable`             | After owner recovery             | `/oauth/native/apple`                                                                                    | The resolved Passport is pending deletion, purged, suspended, or unable to authenticate.                                                       |
| `409`                                     | `consent_required`                | In hosted flow                   | `/oauth/native/apple`                                                                                    | The owner has not previously consented to this exact client. Open the returned `consent_url`.                                                  |
| `409`                                     | `linking_required`                | After owner recovery             | `/oauth/native/apple`                                                                                    | The Apple subject and verified login email cannot be linked without an owner-mediated ceremony.                                                |
| `409`                                     | `command_conflict`                | No                               | `/oauth/grant-revocation`                                                                                | The revocation handle was already consumed with a different command id.                                                                        |
| `410`                                     | `revocation_handle_expired`       | Reauthorize                      | `/oauth/grant-revocation`                                                                                | The revocation handle is unknown or does not belong to the supplied client.                                                                    |
| `429`                                     | `rate_limited`                    | Yes                              | `/oauth/native/apple`, `/oauth/token-info`, `/oauth/grant-revocation`                                    | The endpoint request limit was exceeded. Retry with backoff.                                                                                   |
| `503`                                     | `dependency_unavailable`          | Yes                              | `/oauth/grant-revocation`                                                                                | Grant revocation persistence is temporarily unavailable. Retry with backoff.                                                                   |
| `503`                                     | `unavailable`                     | Yes                              | `/oauth/native/apple`                                                                                    | Apple key retrieval, account resolution, or exchange persistence is temporarily unavailable.                                                   |
| `405`                                     | `method_not_allowed`              | After correction                 | `/authorize`, `/token`, `/register`, `/revoke`                                                           | The endpoint does not support that HTTP method.                                                                                                |
| `429`                                     | `too_many_requests`               | Yes                              | `/authorize`, `/token`, `/register`, `/revoke`                                                           | The SDK endpoint rate limit was exceeded. Honor its retry headers.                                                                             |
| `302` or `500`                            | `server_error`                    | Yes                              | `/authorize`, `/token`, `/register`, `/revoke`, `/userinfo`, `/oauth/token-info`                         | The authorization server or identity claim lookup failed. Retry with backoff.                                                                  |

JSON errors from OAuth and OpenID Connect endpoints include `docs_url` with
this catalog. Authorization redirects and MCP errors keep their protocol
shapes, so this page is their document-only reference.

Clients shall tolerate unknown error strings. Use the HTTP status as the
fallback retry class and retain the string for diagnostics.

**A denied consent redirects to your exact registered callback.**

```text
https://acme.example/callback
  ?error=access_denied
  &state=RANDOM_STATE
  &iss=https%3A%2F%2Fpassport.ego.ist
```

**A spent authorization code returns status 400 at the token endpoint.**

```json
{
  "error": "invalid_grant",
  "error_description": "invalid_grant",
  "request_id": "12e5b34b-67ca-4af7-b93c-26d5540da891",
  "docs_url": "https://ego.ist/docs/sign-in#errors"
}
```

**An expired UserInfo token returns status 401 with this challenge.**

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="invalid_token", scope="openid"
Content-Type: application/json; charset=utf-8

{"error":"invalid_token","error_description":"invalid_token","request_id":"12e5b34b-67ca-4af7-b93c-26d5540da891","docs_url":"https://ego.ist/docs/sign-in#errors"}
```

**A valid identity-only token returns status 403 at the memory resource.**

```http
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", error_description="Insufficient scope", scope="memory", resource_metadata="https://passport.ego.ist/.well-known/oauth-protected-resource/mcp"
Content-Type: application/json; charset=utf-8

{"error":"insufficient_scope","error_description":"Insufficient scope"}
```

## How to test

The live issuer keeps Dynamic Client Registration for compatibility. A DCR
registration does not admit a redirect host. An unadmitted client reaches its
callback with `error=unauthorized_client`. This is the expected live result.

For CIMD, serve the metadata document at its exact client id URL. Admission
is automatic for host-consistent clients, including documents with loopback
redirects. Send DCR clients and non-host-consistent clients through the
[Going live checklist](/docs/going-live). Test against the live issuer with
your exact `client_id`. Personal agents can use
[device authorization](#device-authorization) without a public callback host.

Test user denial, a grant without `memory`, atomic refresh replacement, one
same-IP recovery retry, UserInfo expiry, and revoke on sign-out. Keep test
accounts free of production user data.

## Complete deprecated DCR fallback example

This walkthrough uses Node's built-in `crypto` and `fetch` APIs. Connect these
functions to your server routes and session store.

It uses deprecated DCR. New apps should use the CIMD path above.

**Discover the provider and register your relying party once.**

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

const ISSUER = "https://passport.ego.ist";
const REDIRECT_URI = "https://acme.example/callback";
const flows = new Map(); // Replace with a server-side session store.

function readCookie(header, name) {
  return String(header || "")
    .split(";")
    .map((part) => part.trim().split("="))
    .find(([key]) => key === name)?.[1] || null;
}

const discovery = await fetch(`${ISSUER}/.well-known/openid-configuration`)
  .then((response) => response.json());

const registration = await fetch(discovery.registration_endpoint, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    client_name: "Acme Notes",
    redirect_uris: [REDIRECT_URI],
    grant_types: ["authorization_code", "refresh_token"],
    response_types: ["code"],
    token_endpoint_auth_method: "none",
  }),
}).then((response) => response.json());

const clientId = registration.client_id; // Store this as durable configuration.
```

**Start sign-in with PKCE, state, nonce, and a browser-bound cookie.**

```js
async function startSignIn(request, response) {
  const random = (bytes) => crypto.randomBytes(bytes).toString("base64url");
  const state = random(16);
  const nonce = random(16);
  const codeVerifier = random(32);
  const codeChallenge = crypto.createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");

  flows.set(state, { nonce, codeVerifier, createdAt: Date.now() });

  const authorizeUrl = new URL(discovery.authorization_endpoint);
  authorizeUrl.search = new URLSearchParams({
    response_type: "code",
    client_id: clientId,
    redirect_uri: REDIRECT_URI,
    scope: "openid profile email memory",
    resource: `${ISSUER}/mcp`,
    state,
    nonce,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  });

  response.setHeader(
    "set-cookie",
    `oidc_state=${state}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`,
  );
  response.writeHead(302, { location: authorizeUrl.toString() }).end();
}
```

**Validate the callback and exchange its one-time code.**

```js
async function handleCallback(request, response) {
  const callback = new URL(request.url, "https://acme.example");
  if (callback.searchParams.get("iss") !== discovery.issuer) {
    throw new Error("Authorization issuer mismatch");
  }
  if (callback.searchParams.get("error")) {
    throw new Error(`Sign-in stopped: ${callback.searchParams.get("error")}`);
  }

  const returnedState = callback.searchParams.get("state");
  const cookieState = readCookie(request.headers.cookie, "oidc_state");
  const flow = flows.get(returnedState);
  if (!flow || returnedState !== cookieState) throw new Error("Invalid or expired state");
  flows.delete(returnedState);

  const tokenResponse = await fetch(discovery.token_endpoint, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: callback.searchParams.get("code"),
      redirect_uri: REDIRECT_URI,
      client_id: clientId,
      code_verifier: flow.codeVerifier,
    }),
  });
  if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${tokenResponse.status}`);
  const tokens = await tokenResponse.json();
  if (!tokens.id_token) throw new Error("Token response has no ID token");

  const claims = await verifyIdToken(tokens.id_token, flow.nonce, tokens.access_token);
  const result = await fetchPassportData(tokens, claims);
  response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(result));
}
```

**Verify the ID token against JWKS and check every required claim.**

```js
async function verifyIdToken(idToken, expectedNonce, accessToken) {
  const [headerPart, payloadPart, signaturePart] = idToken.split(".");
  const header = JSON.parse(Buffer.from(headerPart, "base64url"));
  const claims = JSON.parse(Buffer.from(payloadPart, "base64url"));
  const { keys } = await fetch(discovery.jwks_uri).then((response) => response.json());
  const jwk = keys.find((key) => key.kid === header.kid);
  if (!jwk) throw new Error("No matching signing key");

  const publicKey = crypto.createPublicKey({ key: jwk, format: "jwk" });
  const valid = crypto.verify(
    "sha256",
    Buffer.from(`${headerPart}.${payloadPart}`),
    publicKey,
    Buffer.from(signaturePart, "base64url"),
  );
  if (!valid) throw new Error("ID token signature verification failed");
  if (claims.iss !== discovery.issuer) throw new Error("Issuer mismatch");
  if (![claims.aud].flat().includes(clientId)) throw new Error("Audience mismatch");
  if (claims.exp < Math.floor(Date.now() / 1000)) throw new Error("ID token expired");
  if (claims.nonce !== expectedNonce) throw new Error("Nonce mismatch");
  const expectedAtHash = crypto.createHash("sha256")
    .update(accessToken, "ascii")
    .digest()
    .subarray(0, 16)
    .toString("base64url");
  if (claims.at_hash !== expectedAtHash) throw new Error("Access token mismatch");
  return claims;
}
```

**Fetch UserInfo and recall memory only when the grant permits it.**

```js
async function fetchPassportData(tokens, claims) {
  const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
  const { StreamableHTTPClientTransport } = await import(
    "@modelcontextprotocol/sdk/client/streamableHttp.js"
  );

  const userinfo = await fetch(discovery.userinfo_endpoint, {
    headers: { authorization: `Bearer ${tokens.access_token}` },
  }).then((response) => response.json());

  let memory = null;
  const grantedScopes = new Set(String(tokens.scope || "").split(" "));
  if (claims.passport?.memory_access && grantedScopes.has("memory")) {
    const transport = new StreamableHTTPClientTransport(
      new URL(claims.passport.mcp_url),
      { requestInit: { headers: { authorization: `Bearer ${tokens.access_token}` } } },
    );
    const client = new Client({ name: "acme-notes", version: "1.0.0" });
    await client.connect(transport);
    memory = await client.callTool({
      name: "recall",
      arguments: { query: "", categories: ["preference", "project"], purpose: "recall" },
    });
    await client.close();
  }
  return { userinfo, memory };
}
```

Support for non-host-consistent clients, DCR admission, or integration issues:
[support@ego.ist](mailto:support@ego.ist). For what AI Passport does with the
memory behind the identity, see
[how your memory is protected](/trust).

## Beyond sign-in: deep integrations

Sign-in serves users who already have a passport. If you want to create
passports inside your own signup flow, link data sources from your own UI,
contribute your records as an attributed memory source, or read them back
under the owner's passes, that is the deep integration program. It is a
managed, server-to-server API with its own documentation at
[Deep integrations](/docs/partners). The two compose: a person who already
has a passport connects to a partner through this sign-in flow, and the same
delegation is created with the owner deciding on our consent screen.
