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.
| 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 |
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
Registration is open, but authorization is not. Any client on the internet can register a name through Dynamic Client Registration, so a self-chosen name proves nothing. What cannot be faked is the redirect target: authorization codes are only ever delivered to a registered redirect URI. AI Passport therefore refuses to authorize a client whose redirect hosts it does not recognize, and the user never sees a screen for it.
Send us the redirect hosts your app will use before you go live, 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. Users need an AI Passport account already: the sign-in gate authenticates people, it does not create accounts. And the identity your app receives is the account, not any marketing profile around it.
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 | Dynamic Client Registration (RFC 7591). Returns a client_id. No client secret. |
GET /authorize | Authorization request. Sends the user through the sign-in gate and the consent screen. |
POST /token | Authorization code and refresh token grants. Returns an id_token when openid was granted. |
GET|POST /userinfo | Identity claims for an access token that carries the openid scope. |
POST /revoke | Token revocation (RFC 7009). Use it when a user signs out of your app. |
/mcp | The memory resource, over streamable HTTP MCP, reachable with the same access token when memory was granted. |
Discovery advertises response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
token_endpoint_auth_methods_supported: ["none"], and
code_challenge_methods_supported: ["S256"]. There is no implicit flow, no
hybrid flow, and no client secret.
The flow
- Your app reads the discovery document and registers once, keeping the
client_id. - 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
Register once and store the client_id. Every registration is normalized to
a public PKCE client, so a client_secret you send is ignored and none is
returned. Registering again mints a different id rather than updating the
first. Treat the id as durable configuration and register again only when
your redirect URIs change.
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 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 | The id returned by registration. |
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. |
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. |
resource | Recommended with memory | https://passport.ego.ist/mcp. Binds the token to the memory resource, which some deployments enforce. |
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
&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. A
request with no scope at all falls back to memory alone, never to identity,
so identity is something you ask for explicitly.
The user has 10 minutes to finish at the gate before the request expires. If
they take longer, start again from /authorize.
Exchange the code
curl -X POST https://passport.ego.ist/token \
-H 'content-type: application/x-www-form-urlencoded' \
-d grant_type=authorization_code \
-d code=THE_CODE \
-d client_id=YOUR_CLIENT_ID \
-d redirect_uri=https://acme.example/callback \
-d code_verifier=YOUR_PKCE_VERIFIER{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"scope": "openid profile email memory",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ii4uLiJ9..."
}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.
{
"iss": "https://passport.ego.ist",
"aud": "YOUR_CLIENT_ID",
"iat": 1786000000,
"exp": 1786003600,
"nonce": "RANDOM_NONCE",
"sub": "9f1c2e6a-...",
"email": "you@example.com",
"email_verified": true,
"name": "Ada Lovelace",
"picture": "https://...",
"passport": {
"issuer": "https://passport.ego.ist",
"mcp_url": "https://passport.ego.ist/mcp",
"memory_access": true
}
}sub is the stable account id and the only safe key for your user records.
The subject type is public, so every app sees the same sub for a given
person. Email addresses change. Do not key on them.
Identity claims appear only when their scope was granted, and only when the
account has them. Treat name, picture, and email as optional in your
data model.
Scopes and consent
Four scopes exist, and there is no wildcard. The right column is the sentence the user reads on the consent screen, which is worth knowing when you decide what to ask for.
| Scope | Releases | Shown to the user as |
|---|---|---|
openid | Turns the request into a sign-in. Releases sub, and makes /userinfo available. | Confirm your identity |
profile | Releases name and picture, when the account has them set. | Your name and profile picture |
email | Releases email and email_verified. | Your email address |
memory | Lets the same access token read memory over MCP. | Read your AI Passport portable memory |
One nuance: the openid sentence appears only when it is the whole request.
When other scopes come along, the consent screen conveys the sign-in by
naming the account being signed in instead.
Ask for what your first screen actually needs. A sign-in that requests
openid profile email is a smaller decision for the user than one that adds
memory. You can send them through /authorize again later with the wider
scope when the feature that needs it appears.
UserInfo
/userinfo returns the same claim set as the ID token for a valid access
token that carries openid. Use it to refresh a profile later, rather than
as a substitute for verifying the ID token at sign-in.
curl https://passport.ego.ist/userinfo \
-H 'authorization: Bearer YOUR_ACCESS_TOKEN'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.
// 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: "personalize onboarding" },
});The scope is permission to ask, not permission to read. Every recall names the memory categories and the purpose it needs, and the owner governs those with passes that are exact to 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, and it stays a proposal until the owner approves
it.
Refresh, expiry, revocation
- Access tokens last 1 hour. ID tokens carry the same 1 hour lifetime.
- Refresh tokens rotate: every exchange returns a new refresh token and retires the one you sent, so store the new one atomically.
- A refresh returns no new ID token. The identity assertion is made once, at
sign-in. Keep your own session after that, or call
/userinfowhen you need current claims. - A refresh can narrow scope but never widen it. Asking for a scope the grant
does not carry fails with
invalid_scope. - Revoke on sign-out:
POST /revokewithtokenand an optionaltoken_type_hint. Per RFC 7009 it answers success for unknown tokens too, so it is never an oracle for whether a token was valid.
# Refresh (rotation is one-shot: store the new pair before you drop the old)
curl -X POST https://passport.ego.ist/token \
-H 'content-type: application/x-www-form-urlencoded' \
-d grant_type=refresh_token \
-d refresh_token=YOUR_REFRESH_TOKEN \
-d client_id=YOUR_CLIENT_ID
# Revoke on sign-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_tokenUsers can revoke your app at any time from their Passport. An account pending deletion stops authorizing immediately. Both surface to you as an ordinary invalid grant or invalid token. Treat those as a signal to start a new sign-in, not as an error to retry.
Errors
Authorization errors come back on your redirect_uri with your state.
Token and resource errors are JSON, in the shape RFC 6749 defines.
| Error | Where | What it means |
|---|---|---|
unauthorized_client | Redirect to your app | Your client is not on the verified list. Nothing was shown to the user. See Before you build. |
access_denied | Redirect to your app | The user pressed Deny on the consent screen. The ticket is consumed, so send them through /authorize again to retry. |
invalid_scope | Redirect to your app | You asked for a scope outside the four supported ones. |
invalid_grant | 400 at /token | The code expired (5 minutes), was already used, belongs to another client, or the redirect_uri does not match the one from /authorize. |
invalid_token | 401 with WWW-Authenticate | The access token expired or was revoked. Refresh it, or start a new sign-in. |
insufficient_scope | 403 with WWW-Authenticate | The token is valid but lacks the scope the endpoint needs: openid for /userinfo, memory for /mcp. Scopes are never widened for you. |
A working example
The repository ships a complete relying party in examples/signin-demo:
discovery, registration, PKCE, the token exchange, ID token verification
against the JWKS, /userinfo, and a memory recall over MCP, in one
dependency-light file you can read end to end.
AI_PASSPORT_ISSUER=https://passport.ego.ist node examples/signin-demo/server.mjs
# then open http://localhost:4000Questions, 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.