Sign in with AI Passport
OpenID Connect sign-in that can bring the user's approved memory along.
Let people sign in to your app with their AI Passport, the way they sign in with Google or Apple. It is standard OpenID Connect, so any OIDC client library works without a custom integration. The difference is what arrives with the identity: if the user allows it, the same token reads the memory they have approved. Your app knows who they are and what they care about on the first screen.
Sign-in is generally available, with automatic admission for host-consistent metadata-document clients. The contract changes only with notice on the changelog.
Users can arrive with approved context when they grant the memory scope.
Your app can start with that context instead of a cold start or twenty
questions.
| Fact | Value |
|---|---|
| Issuer | https://passport.ego.ist |
| Protocol | OpenID Connect on OAuth 2.1 |
| Client type | Public; PKCE S256 required for redirect sign-in |
| ID token | RS256, valid 1 hour |
| Scopes | openid, profile, email, memory |
| Access token | 1 hour, refresh rotates |
Drop-in button and SDK
Install the official package for the sign-in control and protocol helper.
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 automatic when every non-loopback redirect host equals the document host or is a strict subdomain. Loopback redirect URIs are exempt. Wildcards, non-loopback IP literals, HTTP on non-loopback hosts, and mixed host trees do not qualify for automatic admission. Clients outside the host-consistent case need manual review; invalid redirect URIs must be corrected. Admission confirms domain control only, not an endorsement or a memory grant.
Dynamic Client Registration lets any client register a name, so a self-chosen name proves nothing. Authorization codes only go to registered redirect URIs. AI Passport refuses a client whose redirect hosts it does not admit. The user never sees a consent screen for that client.
Send non-host-consistent CIMD hosts and DCR admission requests through the
developer contact form 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 controlled by an operator flag. Where that rollout is active, a new email can create an AI Passport and acknowledges the terms before returning to your app; full onboarding continues on my.ego.ist afterward. Where it is off, the gate signs in existing Passports only. The identity your app receives is the account, not any marketing profile around it.
Use an approved sign-in label and give it parity with other providers. See the Brand guidelines 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 /oauth/device_authorization | Begin owner-approved pairing for an agent without a public host. |
POST /connect-code/redeem | Redeem an owner-minted one-time connect code. |
POST /token | Authorization code, device code, and refresh token grants. Returns an id_token when openid was granted. |
POST /oauth/token-info | Self-introspection for an access token. Returns its server-derived client and owner bindings. |
GET|POST /userinfo | Identity claims for an access token that carries the openid scope. |
POST /revoke | Token revocation (RFC 7009). Use it when a user signs out of your app. |
/mcp | The memory resource, over streamable HTTP MCP, reachable with the same access token when memory was granted. |
POST /memory/v1/recall | User-present structured memory recall for an admitted client whose token carries memory. |
Discovery advertises response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
token_endpoint_auth_methods_supported: ["none"], and
code_challenge_methods_supported: ["S256"]. It also advertises
authorization_response_iss_parameter_supported: true. There is no implicit flow, no
hybrid flow, and no client secret.
Discovery also advertises
client_id_metadata_document_supported: true.
Structured memory recall
An admitted native app can request machine-readable normal memory while the
user is present. Send an OAuth access token with the memory scope to
POST /memory/v1/recall. The token decides the Passport owner and OAuth
client. Do not put either identifier in the body.
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.
Top-level approval_required takes priority over locked. A locked category
may carry unlock_url for an owner who must unlock once. Temporary scoped
custody failures return unavailable; retry in a few seconds without asking
the owner to unlock the whole vault. Neither state means the user has no preferences. An empty semantic match does not
spend a one-time pass.
The only supported purposes are recall and separately enabled
personalize. They require different passes. This endpoint never returns
protected memory, live connector output, or partner workspace content, and
its approval grants none of those permissions.
Fetch discovery and use the returned endpoint URLs.
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",
"device_authorization_endpoint": "https://passport.ego.ist/oauth/device_authorization",
"token_endpoint": "https://passport.ego.ist/token",
"introspection_endpoint": "https://passport.ego.ist/oauth/token-info",
"userinfo_endpoint": "https://passport.ego.ist/userinfo",
"jwks_uri": "https://passport.ego.ist/.well-known/jwks.json",
"registration_endpoint": "https://passport.ego.ist/register",
"revocation_endpoint": "https://passport.ego.ist/revoke",
"scopes_supported": ["openid", "profile", "email", "memory"],
"response_types_supported": ["code"],
"response_modes_supported": ["query"],
"grant_types_supported": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"token_endpoint_auth_methods_supported": ["none"],
"introspection_endpoint_auth_methods_supported": ["none"],
"code_challenge_methods_supported": ["S256"],
"claims_supported": [
"sub", "iss", "aud", "exp", "iat", "nonce", "at_hash", "email",
"email_verified", "name", "picture", "passport"
]
}passport is a structured claim containing issuer, mcp_url, and
memory_access. With granted memory and a completed lookup, it can also
contain memory_locked and connectors; these are nested fields, not
separate top-level identity claims.
Fetch the current public signing keys.
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. 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 non-loopback redirect host must equal the document host or be its strict subdomain for automatic admission. Loopback redirect URIs are exempt. A valid document outside this host relationship needs manual review and receives no authorization screen until admitted.
Deprecated but supported: Dynamic Client Registration
DCR remains deprecated and compatible. DCR clients still need manual admission.
Use registerClient(...) from ai-passport-signin/server or POST /register.
Each DCR registration is a public PKCE client. A client_secret is ignored and
none is returned. Registering again creates a new client id.
Register one public client for your redirect URI.
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,
"memory_locked": false,
"connectors": ["google-calendar"]
}
}sub is the stable account id and the only safe key for your user records.
The subject type is public, so every app sees the same sub for a given
person. Email addresses change. Do not key on them.
passport.memory_locked and passport.connectors appear only when memory
was granted and the lookup completed in time. memory_locked: true means the
owner must unlock once before any app can read. A sealed vault with automatic
pass-scoped access is not locked in this sense. connectors lists linked
connector slugs, not permission to read them. Missing fields mean unknown, not
false or an empty list.
Identity claims appear only when their scope was granted, and only when the
account has them. Treat name, picture, and email as optional in your
data model.
Scopes and consent
Four scopes exist, and there is no wildcard. The right column is the sentence the user reads on the consent screen, which is worth knowing when you decide what to ask for.
| Scope | Releases | Shown to the user as |
|---|---|---|
openid | Turns the request into a sign-in. Releases sub, and makes /userinfo available. | Confirm your identity |
profile | Releases name and picture, when the account has them set. | See your name and profile picture |
email | Releases email and email_verified. | See your email address |
memory | Lets the same access token read memory over MCP. | Read your AI Passport portable memory |
One nuance: the openid sentence appears only when it is the whole request.
When other scopes come along, the consent screen conveys the sign-in by
naming the account being signed in instead.
Ask for what your first screen actually needs. A sign-in that requests
openid profile email is a smaller decision for the user than one that adds
memory. You can send them through /authorize again later with the wider
scope when the feature that needs it appears.
UserInfo
/userinfo returns the same scoped identity and passport claims. It omits
ID token fields such as iss, aud, iat, exp, and nonce. Use UserInfo
to refresh a profile later. Do not use it instead of ID token verification.
The same memory-scope and completed-lookup rule governs memory_locked and
connectors here. Either optional field may be absent.
Fetch the current profile with the access token.
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,
"memory_locked": false,
"connectors": ["google-calendar"]
}
}Prove a token belongs to your client
The initial code exchange binds its access token to the signed ID token through
at_hash. Verify that claim before using the pair. This check is local and
requires no network request beyond the JWKS lookup used for signature
verification.
For a refreshed access token, or after the retained ID token expires, call the
discovered self-introspection endpoint with the access token as its bearer.
Public PKCE clients have no client secret, so the credential authenticates
itself. An optional form or JSON token parameter is accepted only when it is
byte-for-byte equal to the bearer.
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
The MCP recall tool reads live connector data through its connectors
parameter as well as governed memory. POST /memory/v1/recall reads governed
memory only and never returns live connector data.
When the user grants memory, the access token you already hold reads their
Passport over MCP at the mcp_url in the passport claim. There is no
second handshake and no second credential. Read passport.memory_access to
know whether this token can do it.
The supported MCP protocol revision is 2025-11-25 through the official SDK.
We adopt a new revision within one release of SDK support.
Discover what the token can do
Call passport_status first after connecting to MCP with a memory-scoped
bearer. It returns a text summary and structuredContent in this shape:
{
"contract_version": 1,
"issuer": "https://passport.ego.ist",
"mcp_url": "https://passport.ego.ist/mcp",
"client": { "client_id": "YOUR_CLIENT_ID", "name": "Acme Notes", "verified": true },
"scopes": ["memory"],
"memory": {
"state": "sealed_owner_unlock_required",
"unlock_url": "https://my.ego.ist/memory-lock",
"categories": ["fact", "preference", "project"],
"passes_known": true,
"passes": [
{ "category": "fact", "purpose": "recall", "duration": "permanent", "expires_at": null }
]
},
"connectors": {
"enabled": [
{ "connector": "google-calendar", "label": "Google Calendar", "data_categories": ["calendar.events"] }
],
"linked": ["google-calendar"],
"linked_known": true,
"passes_known": true,
"passes": [
{ "connector": "google-calendar", "data_category": "calendar.events", "duration": "permanent", "expires_at": null }
],
"connect_url": "https://my.ego.ist/connectors"
},
"owner_urls": {
"inbox": "https://my.ego.ist/inbox",
"connectors": "https://my.ego.ist/connectors",
"memory_lock": "https://my.ego.ist/memory-lock"
}
}client identifies this exact OAuth client. A caller-chosen agent name is
unverified; a CIMD verification badge confirms domain control only. The
listed passes belong to this app, never to every app the owner has connected.
Each passes_known flag describes its own list. False means that lookup
failed; an empty list with true means the app has no active passes of that type.
memory.state | Meaning and action |
|---|---|
open | No memory lock; category passes still govern reads |
sealed_auto_unlock | Sealed vault with automatic scoped access for apps holding a pass |
sealed_owner_unlock_required | Owner must unlock once; memory.unlock_url is present and points to https://my.ego.ist/memory-lock |
unknown | The read failed; retry rather than treating memory as empty |
connectors.enabled lists available connectors and exact data categories.
linked lists sources the owner has connected. If linked_known is false,
the list may be partial, or null if the lookup could not return any result.
Retry to learn about missing sources. Linking a source does not grant this
app a connector pass. Use the supplied owner URLs for the corresponding task.
Read live data with the MCP recall tool's connectors parameter, using
slugs and categories from status or the current tool schema:
await client.callTool({
name: "recall",
arguments: {
query: "upcoming meetings",
connectors: [{ connector: "google-calendar", data_category: "calendar.events" }],
},
});POST /memory/v1/recall reads governed memory only. Prefer Passport's linked
sources for live calendar, mail, code, and fitness data instead of asking the
owner to connect the same source through an agent's native connector.
Read and propose memory
Call the recall MCP tool with the same access token.
The MCP tool always requests the controlled purpose recall, meaning read
memory to answer the user. AI Passport also defines a separately enabled
normal-memory purpose, personalize, for admitted relying-app operations that
use approved preferences to personalize visible results. Each purpose needs
its own exact app and category pass. A pass for one never authorizes the other.
// Streamable HTTP MCP client, same bearer token
const transport = new StreamableHTTPClientTransport(new URL(claims.passport.mcp_url), {
requestInit: { headers: { Authorization: `Bearer ${accessToken}` } },
});
await client.connect(transport);
await client.callTool({ name: "passport_status", arguments: {} });
await client.callTool({
name: "recall",
arguments: { query: "", categories: ["preference", "project"], purpose: "recall" },
});A successful tool call returns formatted memory as text content.
{
"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.
Approval notices survive a locked recall and always take priority over unlock guidance. Show the exact approval link when a category has no pass. If scoped custody is temporarily unavailable, retry in a few seconds; do not send the owner to unlock the whole vault. Only a vault whose owner has not completed the required one-time re-escrow returns an owner unlock link. Passes can be one-time, session, or permanent, and the owner may configure auto-approval. Memory passes, protected-memory approvals, and connector passes are separate.
A sign-in without the memory scope cannot read anything at /mcp. Anything
your app writes back is a proposal that lands in the owner inbox, not a
memory other apps can see. It stays pending until the owner or their
configured review rule approves it.
Submit new normal memory as a proposal for owner review.
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."
}
]
}Device authorization
Personal and headless agents without a public host can use RFC 8628 device
authorization. They do not need a callback route or a public tunnel. Discovery
advertises device_authorization_endpoint as
https://passport.ego.ist/oauth/device_authorization, and
grant_types_supported includes urn:ietf:params:oauth:grant-type:device_code.
- The agent begins a pairing and receives a device code, owner-facing code, verification URL, expiry, and polling interval.
- The agent shows the owner the code and verification URL.
- The owner signs in on my.ego.ist, checks the agent name and code, and chooses Allow or Deny. Every device-paired agent has an unverified warning; the owner must confirm that the pairing started from their own agent.
- The agent polls until the owner decides or the pairing expires, then stores the returned token pair securely.
Begin a pairing
Send form data or JSON to POST /oauth/device_authorization. Use client_name
for a new agent, or reuse a client id returned by an earlier device pairing.
Every device-paired agent is shown to the owner as unverified.
| Field | Contract |
|---|---|
client_id | Optional, accepted only for a client id returned by an earlier device pairing; otherwise send client_name |
client_name | Agent's own name, 1 to 64 characters, shown as unverified |
scope | Subset of openid profile email memory messaging; messaging is available when agent messaging is enabled on the deployment. Defaults to openid memory |
Purpose scopes booking:actions and connector:reads are not available
through this grant. Admission and token issuance do not create any pass.
curl -X POST https://passport.ego.ist/oauth/device_authorization \
-H 'content-type: application/json' \
-d '{"client_name":"Hana","scope":"openid memory"}'{
"device_code": "DEVICE_CODE",
"user_code": "ABCD-EFGH",
"verification_uri": "https://my.ego.ist/device",
"verification_uri_complete": "https://my.ego.ist/device?code=ABCD-EFGH",
"expires_in": 600,
"interval": 5,
"client_id": "YOUR_CLIENT_ID"
}Keep the device code private. Show only the user code and verification URL to
the owner. Retain the given or server-assigned client_id for the token request.
The owner-facing code is formatted XXXX-XXXX and expires after 10 minutes.
Poll for approval
Wait at least the returned interval before each request. Send form data to
POST /token with the exact client id and device code from this pairing:
curl -X POST https://passport.ego.ist/token \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code' \
--data-urlencode 'device_code=DEVICE_CODE' \
--data-urlencode 'client_id=YOUR_CLIENT_ID'| Error | Action |
|---|---|
authorization_pending | Continue polling at the current interval |
slow_down | Add 5 seconds to the interval for this and every subsequent poll |
access_denied | Stop; a new pairing requires another owner action |
expired_token | Stop; offer a new pairing if the owner still wants to connect |
invalid_grant | Stop; the code is invalid, consumed, or bound to another client |
Success returns the normal token response plus mcp_url. An id_token is
present when openid was granted, with at_hash and the passport claim.
Verify JWKS signature, issuer, audience, expiry, and at_hash before trusting
identity claims. This grant has no nonce. Refresh uses the ordinary
refresh_token grant at https://passport.ego.ist/token; replace both tokens
atomically and honor the returned scope.
Use createDevicePairing from ai-passport-signin/server for begin, one-shot
polling, or waitForApproval(session, { signal }). See the
personal agent guide for the SDK, curl sequence, and prompts.
Owner-minted connect codes
An owner can mint a one-time code on my.ego.ist under Assistants or the install
step in onboarding. It expires in 10 minutes and works once. Redeem it with
JSON at https://passport.ego.ist/connect-code/redeem:
{ "code": "XXXX-XXXX", "app": "other", "client_name": "Hana" }Families are openclaw, hermes, opencode, muse (Muse), and other.
other accepts any agent name and displays it to the owner as unverified.
The response contains mcp_url, access_token, token_type, expires_in,
refresh_token, scope, client_id, and token_url. The mint response reports
the scope the code will carry. It is memory messaging
when the messaging service is mounted, agent messaging is enabled, and the owner
is in its rollout; otherwise it is memory.
The server checks the service and owner rollout again at redemption. If either
changes during the code's 10-minute lifetime, the redeemed scope can differ.
An existing memory-only install does not gain messaging; the owner must mint a new
code and the agent must redeem it. The response does not return an OpenID
identity token. Call passport_status after connecting.
Request a travel document disclosure
Travel documents never enter your app's agent context. A non-chat-surface
OAuth client uses request_disclosure with one exact HTTPS destination, only
the fields that destination needs, and a controlled purpose
(travel_booking for a booking handoff, otherwise the default
directed_disclosure). The owner reviews the request in AI Passport's browser
approval flow, and Passport delivers the approved subset once. Every delivery
carries a stable Idempotency-Key and a short-lived signed
Passport-Disclosure-Attestation header that your destination must verify
before it reads the body. Do not treat the approval link or the owner's
approval as proof that delivery succeeded. Wait for the terminal delivered or
delivery-failed result. A delivery the destination never answered stays
pending and is not sent again. The
directed disclosure guide 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 correcting client standing | /authorize callback | A non-host-consistent CIMD client or DCR client needs manual admission, or the client is suspended or revoked. The user saw no consent screen. |
302 | access_denied | User choice | /authorize callback | The user denied consent. Start a new authorization only after another user action. |
302 or 400 | invalid_scope | After correction | /authorize, /token | The scope is missing, unsupported, restricted, or wider than the refresh grant. |
302 | invalid_target | After correction | /authorize callback | A memory request omitted the MCP resource or named a different resource. |
400 | invalid_grant | Reauthorize | /token, /oauth/native/apple | The code, refresh grant, pending transaction, or Apple assertion is expired, spent, mismatched, revoked, or outside recovery. |
400 | unsupported_grant_type | After correction | /token | The requested grant type is not supported. |
400 | invalid_client_metadata | After correction | /register | Dynamic client metadata is malformed or unsupported. |
401 | invalid_token | Refresh or reauthorize | /userinfo, /oauth/token-info, /mcp | The access token is absent, expired, revoked, unbound, or no longer active. |
403 | insufficient_scope | Reauthorize | /userinfo, /mcp | The valid token lacks openid or memory for that resource. |
403 | user_delegation_required | After sign-in | /oidc/lifecycle/bind | The access token does not carry a user delegation for this client. |
400 | invalid_external_subject | After correction | /oidc/lifecycle/bind | The opaque subject is missing, too long, or malformed. |
403 | lifecycle_registration_required | After operator admission | /oidc/lifecycle/bind | The token's exact client has no lifecycle receiver registration. |
403 | lifecycle_registration_disabled | After operator review | /oidc/lifecycle/bind | The client's lifecycle receiver registration is disabled. |
503 | lifecycle_rollout_disabled | After operator enablement | /oidc/lifecycle/bind | The lifecycle database rollout switch is deliberately paused. |
409 | account_purged | No | /oidc/lifecycle/bind | The Passport owner has a terminal lifecycle purge fence. A relying party cannot clear it. |
409 | binding_conflict | After disconnect | /oidc/lifecycle/bind | The client and user already have a different live binding. |
409 | subject_conflict | After disconnect | /oidc/lifecycle/bind | The opaque subject is already bound to a different user. |
503 | lifecycle_unavailable | Yes | /oidc/lifecycle/bind | Binding persistence is temporarily unavailable. Retry with backoff. |
403 | account_unavailable | After owner recovery | /oauth/native/apple | The resolved Passport is pending deletion, purged, suspended, or unable to authenticate. |
409 | consent_required | In hosted flow | /oauth/native/apple | The owner has not previously consented to this exact client. Open the returned consent_url. |
409 | linking_required | After owner recovery | /oauth/native/apple | The Apple subject and verified login email cannot be linked without an owner-mediated ceremony. |
409 | command_conflict | No | /oauth/grant-revocation | The revocation handle was already consumed with a different command id. |
410 | revocation_handle_expired | Reauthorize | /oauth/grant-revocation | The revocation handle is unknown or does not belong to the supplied client. |
429 | rate_limited | Yes | /oauth/native/apple, /oauth/token-info, /oauth/grant-revocation | The endpoint request limit was exceeded. Retry with backoff. |
503 | dependency_unavailable | Yes | /oauth/grant-revocation | Grant revocation persistence is temporarily unavailable. Retry with backoff. |
503 | unavailable | Yes | /oauth/native/apple | Apple key retrieval, account resolution, or exchange persistence is temporarily unavailable. |
405 | method_not_allowed | After correction | /authorize, /token, /register, /revoke | The endpoint does not support that HTTP method. |
429 | too_many_requests | Yes | /authorize, /token, /register, /revoke | The SDK endpoint rate limit was exceeded. Honor its retry headers. |
302 or 500 | server_error | Yes | /authorize, /token, /register, /revoke, /userinfo, /oauth/token-info | The authorization server or identity claim lookup failed. Retry with backoff. |
JSON errors from OAuth and OpenID Connect endpoints include docs_url with
this catalog. Authorization redirects and MCP errors keep their protocol
shapes, so this page is their document-only reference.
Clients shall tolerate unknown error strings. Use the HTTP status as the fallback retry class and retain the string for diagnostics.
A denied consent redirects to your exact registered callback.
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, serve the metadata document at its exact client id URL. Admission
is automatic for host-consistent clients, including documents with loopback
redirects. Send DCR clients and non-host-consistent clients through the
Going live checklist. Test against the live issuer with
your exact client_id. Personal agents can use
device authorization without a public callback host.
Test user denial, a grant without memory, atomic refresh replacement, one
same-IP recovery retry, UserInfo expiry, and revoke on sign-out. Keep test
accounts free of production user data.
Complete deprecated DCR fallback example
This walkthrough uses Node's built-in crypto and fetch APIs. Connect these
functions to your server routes and session store.
It uses deprecated DCR. New apps should use the CIMD path above.
Discover the provider and register your relying party once.
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 };
}Support for non-host-consistent clients, DCR admission, or integration issues: 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.