# 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.
------------------------------------------------------------------------------------

Private beta. Onboarding is by request. The contract can change with notice while in beta.

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.

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             |
| 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, and the nonce. 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 rolling out. A hosted client metadata document can use the
self-serve path when every redirect host equals its document host or is a
strict subdomain. Other clients remain on the manual review path.

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 hosts that need manual review 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 rolling out behind 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.

### iOS and App Store Guideline 4.8

AI Passport does not yet qualify as the equivalent login service option under
App Store Review Guideline 4.8. Qualification is planned.

When Guideline 4.8 applies, pair AI Passport with Sign in with Apple. Do not
use AI Passport as a replacement for the required Apple option.

## 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 /token`                           | Authorization code and refresh token grants. Returns an `id_token` when `openid` was granted.                  |
| `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. |

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.

When CIMD is available, discovery also advertises
`client_id_metadata_document_supported: true`.

**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",
  "token_endpoint": "https://passport.ego.ist/token",
  "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"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported": ["S256"],
  "claims_supported": [
    "sub", "iss", "aud", "exp", "iat", "nonce", "email",
    "email_verified", "name", "picture", "passport"
  ]
}
```

**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 and is rolling out. 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.

**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"],
  }));
}
```

Every redirect host must equal the document host or be its strict subdomain
for automatic admission. That self-serve admission is rolling out. A document
outside this host relationship stays on the existing manual review path. It
does not receive an authorization screen until the review admits it.

### Deprecated but supported: Dynamic Client Registration

DCR remains available for existing clients and clients awaiting manual review.
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 10 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.

**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");
```

**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",
  "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
  }
}
```

`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.

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.                      | Your name and profile picture         |
| `email`   | Releases `email` and `email_verified`.                                             | 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.

**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
  }
}
```

## Bring the memory along

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.

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

```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: "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.

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."
    }
  ]
}
```

## 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.
* A same-client-IP replay within five minutes returns the exact prior response.
  Use this only when delivery is unknown.
* A replay after five minutes revokes only that token family. Start a new
  authorization after `invalid_grant`.
* A refresh returns no new ID token. The identity assertion is made once, at
  sign-in. Keep your own session after that, or call `/userinfo` when you
  need current 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`. 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.**

```bash
curl -X POST https://passport.ego.ist/token \
  -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.

## 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`                           | 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 admission        | `/authorize` callback                                       | The redirect host is not verified. 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`                                                    | The code or refresh grant 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`, `/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.                          |
| `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` | 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

Start with the local backend. Loopback redirect hosts bypass live admission,
so the demo can complete registration, authorization, denial, token exchange,
refresh, UserInfo, and MCP calls.

**Run the local relying-party demo.**

```bash
npm run dev

# In another terminal
cd examples/signin-demo
AI_PASSPORT_ISSUER=http://localhost:3000 node server.mjs
```

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, first confirm live discovery advertises support. Then serve the
metadata document at its exact client id URL. Host-consistent redirects can use
the self-serve path as it becomes available. Send DCR clients and other hosts
through the [Going live checklist](/docs/going-live). After admission, run the
demo with the live issuer and the admitted `client_id`.

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);
  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) {
  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");
  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 };
}
```

Questions, or a redirect host to admit:
[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.
