AI Passport developer docs

Sign in with AI Passport

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.

FactValue
Issuerhttps://passport.ego.ist
ProtocolOpenID Connect on OAuth 2.1
Client typePublic, PKCE S256 required
ID tokenRS256, valid 1 hour
Scopesopenid, profile, email, memory
Access token1 hour, refresh rotates

Drop-in button and SDK

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

npm install ai-passport-signin

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

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

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

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

Create server-only begin and callback handlers.

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 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 or at 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 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 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 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.

EndpointPurpose
GET /.well-known/openid-configurationDiscovery document. Read it at startup and take every other URL from it rather than hardcoding paths.
GET /.well-known/jwks.jsonRS256 public signing keys for ID token verification, keyed by kid.
POST /registerDeprecated Dynamic Client Registration (RFC 7591). Returns a client_id. No client secret.
GET /authorizeAuthorization request. Sends the user through the sign-in gate and the consent screen.
POST /tokenAuthorization code and refresh token grants. Returns an id_token when openid was granted.
POST /oauth/token-infoSelf-introspection for an access token. Returns its server-derived client and owner bindings.
GET|POST /userinfoIdentity claims for an access token that carries the openid scope.
POST /revokeToken revocation (RFC 7009). Use it when a user signs out of your app.
/mcpThe memory resource, over streamable HTTP MCP, reachable with the same access token when memory was granted.
POST /memory/v1/recallUser-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.

When CIMD is available, 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.

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.

{
  "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. Neither 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.

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

The discovery response describes the supported OpenID Connect surface.

{
  "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",
  "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"],
  "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"
  ]
}

Fetch the current public signing keys.

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

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

{
  "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.

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.

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.

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.

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

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.

{
  "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

ParameterPresenceNotes
response_typeRequiredcode
client_idRequiredYour hosted Client Identifier URL, or the id returned by deprecated DCR.
redirect_uriRequiredMust exactly match a URI you registered.
scopeRequiredSpace separated. Include openid, or you get a plain OAuth grant with no identity assertion. An omitted or empty value returns invalid_scope.
stateRequired in practiceYour CSRF value. It is echoed back on both success and failure.
code_challengeRequiredBase64url SHA-256 of your PKCE verifier.
code_challenge_methodRequiredS256. The plain method is not offered.
nonceRecommendedEchoed into the ID token so you can bind the token to this request. Send it and check it.
login_hintOptionalAn email address to prefill on the hosted sign-in form. It never skips authentication.
promptOptionalSend 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.
resourceRequired with memoryMust be https://passport.ego.ist/mcp. A missing or different target returns invalid_target.

Send the browser to this authorization URL.

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.

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.

{
  "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.

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.

{
  "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
  }
}

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.

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.

ScopeReleasesShown to the user as
openidTurns the request into a sign-in. Releases sub, and makes /userinfo available.Confirm your identity
profileReleases name and picture, when the account has them set.See your name and profile picture
emailReleases email and email_verified.See your email address
memoryLets 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.

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

UserInfo releases only claims covered by the granted scopes.

{
  "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
  }
}

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.

curl -X POST https://passport.ego.ist/oauth/token-info \
  -H 'authorization: Bearer YOUR_ACCESS_TOKEN'
{
  "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

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.

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.

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

{
  "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.

{
  "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.

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.

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

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

{
  "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.
  • 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.

RetryResult within five minutes
Same old token and same rotation idThe original response is returned.
Same old token and another rotation idinvalid_grant; the successor stays protected.
No rotation header, same IPThe original response is returned.
No rotation header, another IPinvalid_grant; the successor stays protected.
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.

{
  "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.

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.

{}

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.

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.

{
  "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 statusErrorRetryEndpointsWhat it means
400, or 302 after redirect validationinvalid_requestAfter correction/authorize, /token, /revoke, /oauth/native/apple, /oauth/token-info, /oauth/grant-revocationA required parameter is missing, malformed, duplicated, or otherwise invalid.
400invalid_clientAfter correction/authorize, /token, /revokeClient authentication failed or the client id is unknown.
302unauthorized_clientAfter admission/authorize callbackThe redirect host is not verified. The user saw no consent screen.
302access_deniedUser choice/authorize callbackThe user denied consent. Start a new authorization only after another user action.
302 or 400invalid_scopeAfter correction/authorize, /tokenThe scope is missing, unsupported, restricted, or wider than the refresh grant.
302invalid_targetAfter correction/authorize callbackA memory request omitted the MCP resource or named a different resource.
400invalid_grantReauthorize/token, /oauth/native/appleThe code, refresh grant, pending transaction, or Apple assertion is expired, spent, mismatched, revoked, or outside recovery.
400unsupported_grant_typeAfter correction/tokenThe requested grant type is not supported.
400invalid_client_metadataAfter correction/registerDynamic client metadata is malformed or unsupported.
401invalid_tokenRefresh or reauthorize/userinfo, /oauth/token-info, /mcpThe access token is absent, expired, revoked, unbound, or no longer active.
403insufficient_scopeReauthorize/userinfo, /mcpThe valid token lacks openid or memory for that resource.
403user_delegation_requiredAfter sign-in/oidc/lifecycle/bindThe access token does not carry a user delegation for this client.
400invalid_external_subjectAfter correction/oidc/lifecycle/bindThe opaque subject is missing, too long, or malformed.
403lifecycle_registration_requiredAfter operator admission/oidc/lifecycle/bindThe token's exact client has no lifecycle receiver registration.
403lifecycle_registration_disabledAfter operator review/oidc/lifecycle/bindThe client's lifecycle receiver registration is disabled.
503lifecycle_rollout_disabledAfter operator enablement/oidc/lifecycle/bindThe lifecycle database rollout switch is deliberately paused.
409account_purgedNo/oidc/lifecycle/bindThe Passport owner has a terminal lifecycle purge fence. A relying party cannot clear it.
409binding_conflictAfter disconnect/oidc/lifecycle/bindThe client and user already have a different live binding.
409subject_conflictAfter disconnect/oidc/lifecycle/bindThe opaque subject is already bound to a different user.
503lifecycle_unavailableYes/oidc/lifecycle/bindBinding persistence is temporarily unavailable. Retry with backoff.
403account_unavailableAfter owner recovery/oauth/native/appleThe resolved Passport is pending deletion, purged, suspended, or unable to authenticate.
409consent_requiredIn hosted flow/oauth/native/appleThe owner has not previously consented to this exact client. Open the returned consent_url.
409linking_requiredAfter owner recovery/oauth/native/appleThe Apple subject and verified login email cannot be linked without an owner-mediated ceremony.
409command_conflictNo/oauth/grant-revocationThe revocation handle was already consumed with a different command id.
410revocation_handle_expiredReauthorize/oauth/grant-revocationThe revocation handle is unknown or does not belong to the supplied client.
429rate_limitedYes/oauth/native/apple, /oauth/token-info, /oauth/grant-revocationThe endpoint request limit was exceeded. Retry with backoff.
503dependency_unavailableYes/oauth/grant-revocationGrant revocation persistence is temporarily unavailable. Retry with backoff.
503unavailableYes/oauth/native/appleApple key retrieval, account resolution, or exchange persistence is temporarily unavailable.
405method_not_allowedAfter correction/authorize, /token, /register, /revokeThe endpoint does not support that HTTP method.
429too_many_requestsYes/authorize, /token, /register, /revokeThe SDK endpoint rate limit was exceeded. Honor its retry headers.
302 or 500server_errorYes/authorize, /token, /register, /revoke, /userinfo, /oauth/token-infoThe 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.

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.

{
  "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/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/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, 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. After admission, run your integration against the live issuer with 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.

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.

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.

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.

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.

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. For what AI Passport does with the memory behind the identity, see how your memory is protected.

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

On this page