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.
| 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.
npm install ai-passport-signinRender 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.
| 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. |
POST /oauth/token-info | Self-introspection for an access token. Returns its server-derived client and owner bindings. |
GET|POST /userinfo | Identity claims for an access token that carries the openid scope. |
POST /revoke | Token revocation (RFC 7009). Use it when a user signs out of your app. |
/mcp | The memory resource, over streamable HTTP MCP, reachable with the same access token when memory was granted. |
POST /memory/v1/recall | User-present structured memory recall for an admitted client whose token carries memory. |
Discovery advertises response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
token_endpoint_auth_methods_supported: ["none"], and
code_challenge_methods_supported: ["S256"]. It also advertises
authorization_response_iss_parameter_supported: true. There is no implicit flow, no
hybrid flow, and no client secret.
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-configurationThe 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.jsonSelect 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
- Your app reads discovery and uses its hosted Client Identifier URL, or registers once with the deprecated DCR endpoint.
- Your app sends the user to
/authorizewith PKCE,state, andnonce. - 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.
- 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.
- On approval, the browser returns to your
redirect_uriwith a code and yourstate. The code is single use and expires in 5 minutes. - Your server exchanges the code at
/tokenfor 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
| 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.
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=S256A 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_VERIFIERA 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.jsonand pick the key whosekidmatches the token header. During a signing key rotation the JWKS carries the retired public key alongside the current one, so select bykidinstead of taking the first key, and refetch when akidis unknown. - Verify the signature, then check
issequals the issuer,audcontains yourclient_id,expis in the future, andnoncematches 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.
Scopes and consent
Four scopes exist, and there is no wildcard. The right column is the sentence the user reads on the consent screen, which is worth knowing when you decide what to ask for.
| Scope | Releases | Shown to the user as |
|---|---|---|
openid | Turns the request into a sign-in. Releases sub, and makes /userinfo available. | Confirm your identity |
profile | Releases name and picture, when the account has them set. | See your name and profile picture |
email | Releases email and email_verified. | See your email address |
memory | Lets the same access token read memory over MCP. | Read your AI Passport portable memory |
One nuance: the openid sentence appears only when it is the whole request.
When other scopes come along, the consent screen conveys the sign-in by
naming the account being signed in instead.
Ask for what your first screen actually needs. A sign-in that requests
openid profile email is a smaller decision for the user than one that adds
memory. You can send them through /authorize again later with the wider
scope when the feature that needs it appears.
UserInfo
/userinfo returns the same scoped identity and passport claims. It omits
ID token fields such as iss, aud, iat, exp, and nonce. Use UserInfo
to refresh a profile later. Do not use it instead of ID token verification.
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 asPassport-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-infofor every refreshed access token and compare its exactclient_idandsub, or reauthorize through/authorize. Use/userinfowhen 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 /revokewithtokenand an optionaltoken_type_hint. Revoking a live or retired refresh token closes its full server-side family, including access-token and refresh-token successors. Per RFC 7009 it answers success for unknown tokens too, so it is never an oracle for whether a token was valid.
Refresh the token pair with the current refresh token.
| Retry | Result within five minutes |
|---|---|
| Same old token and same rotation id | The original response is returned. |
| Same old token and another rotation id | invalid_grant; the successor stays protected. |
| No rotation header, same IP | The original response is returned. |
| No rotation header, another IP | invalid_grant; the successor stays protected. |
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/mcpAudience 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_IDRevocation 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
typequal tosecevent+jwt issequal to the canonical Passport issueraudequal to your separately provisioned lifecycle audience- a decimal-string
jtiwithin signed 64-bit range sub_idequal 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 status | Error | Retry | Endpoints | What it means |
|---|---|---|---|---|
400, or 302 after redirect validation | invalid_request | After correction | /authorize, /token, /revoke, /oauth/native/apple, /oauth/token-info, /oauth/grant-revocation | A required parameter is missing, malformed, duplicated, or otherwise invalid. |
400 | invalid_client | After correction | /authorize, /token, /revoke | Client authentication failed or the client id is unknown. |
302 | unauthorized_client | After 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, /oauth/native/apple | The code, refresh grant, pending transaction, or Apple assertion is expired, spent, mismatched, revoked, or outside recovery. |
400 | unsupported_grant_type | After correction | /token | The requested grant type is not supported. |
400 | invalid_client_metadata | After correction | /register | Dynamic client metadata is malformed or unsupported. |
401 | invalid_token | Refresh or reauthorize | /userinfo, /oauth/token-info, /mcp | The access token is absent, expired, revoked, unbound, or no longer active. |
403 | insufficient_scope | Reauthorize | /userinfo, /mcp | The valid token lacks openid or memory for that resource. |
403 | user_delegation_required | After sign-in | /oidc/lifecycle/bind | The access token does not carry a user delegation for this client. |
400 | invalid_external_subject | After correction | /oidc/lifecycle/bind | The opaque subject is missing, too long, or malformed. |
403 | lifecycle_registration_required | After operator admission | /oidc/lifecycle/bind | The token's exact client has no lifecycle receiver registration. |
403 | lifecycle_registration_disabled | After operator review | /oidc/lifecycle/bind | The client's lifecycle receiver registration is disabled. |
503 | lifecycle_rollout_disabled | After operator enablement | /oidc/lifecycle/bind | The lifecycle database rollout switch is deliberately paused. |
409 | account_purged | No | /oidc/lifecycle/bind | The Passport owner has a terminal lifecycle purge fence. A relying party cannot clear it. |
409 | binding_conflict | After disconnect | /oidc/lifecycle/bind | The client and user already have a different live binding. |
409 | subject_conflict | After disconnect | /oidc/lifecycle/bind | The opaque subject is already bound to a different user. |
503 | lifecycle_unavailable | Yes | /oidc/lifecycle/bind | Binding persistence is temporarily unavailable. Retry with backoff. |
403 | account_unavailable | After owner recovery | /oauth/native/apple | The resolved Passport is pending deletion, purged, suspended, or unable to authenticate. |
409 | consent_required | In hosted flow | /oauth/native/apple | The owner has not previously consented to this exact client. Open the returned consent_url. |
409 | linking_required | After owner recovery | /oauth/native/apple | The Apple subject and verified login email cannot be linked without an owner-mediated ceremony. |
409 | command_conflict | No | /oauth/grant-revocation | The revocation handle was already consumed with a different command id. |
410 | revocation_handle_expired | Reauthorize | /oauth/grant-revocation | The revocation handle is unknown or does not belong to the supplied client. |
429 | rate_limited | Yes | /oauth/native/apple, /oauth/token-info, /oauth/grant-revocation | The endpoint request limit was exceeded. Retry with backoff. |
503 | dependency_unavailable | Yes | /oauth/grant-revocation | Grant revocation persistence is temporarily unavailable. Retry with backoff. |
503 | unavailable | Yes | /oauth/native/apple | Apple key retrieval, account resolution, or exchange persistence is temporarily unavailable. |
405 | method_not_allowed | After correction | /authorize, /token, /register, /revoke | The endpoint does not support that HTTP method. |
429 | too_many_requests | Yes | /authorize, /token, /register, /revoke | The SDK endpoint rate limit was exceeded. Honor its retry headers. |
302 or 500 | server_error | Yes | /authorize, /token, /register, /revoke, /userinfo, /oauth/token-info | The authorization server or identity claim lookup failed. Retry with backoff. |
JSON errors from OAuth and OpenID Connect endpoints include docs_url with
this catalog. Authorization redirects and MCP errors keep their protocol
shapes, so this page is their document-only reference.
Clients shall tolerate unknown error strings. Use the HTTP status as the fallback retry class and retain the string for diagnostics.
A denied consent redirects to your exact registered callback.
https://acme.example/callback
?error=access_denied
&state=RANDOM_STATE
&iss=https%3A%2F%2Fpassport.ego.istA 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.