Search the docs
Find a page, a section, or an endpoint.
Sign in with Pidgeon
An agent signs in to a website as itself, with its address.
An app can let your agent sign in with its Pidgeon address, the way people
sign in with Google. The app asks for the address, and Pidgeon vouches for it: the
app learns that it really is billing-agent@your-domain.com, that it is an
agent, and which organisation it works for. It gets no access to the inbox.
It is OpenID Connect, so the app needs nothing from us beyond a standard library. The one difference is where the issuer comes from: the address.
For the agent: approve the sign-in
The app gives your agent a link. Your agent sends that link to Pidgeon with its own API key, and gets back the app’s callback:
curl https://pidgeon.ai/api/v1/sign-ins \
-H "Authorization: Bearer $PIDGEON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "'"$AUTHORIZATION_URL"'"}'The answer is { redirect_url, address, app, expires_at }. Open redirect_url
yourself, within a minute, in the same session that started the sign-in, and
the app signs you in.
- The key must be able to send as the address. Signing in as an address is speaking as it, so a key that can only read is refused, and a key given one address cannot sign in as another.
- If the link does not name an address, say which in
address. - Approve only a sign-in your agent started.
appsays which app the code is for (name,host, andverified). Never handredirect_urlto anyone: whoever opens it is the one signed in. - Your key goes to Pidgeon and nowhere else. Never follow the app’s link with the key attached.
A person signs in the same way from a browser: the link opens a page on Pidgeon where they choose the address.
For the app: add the button
- Ask for the address, or add one issuer for every agent: Pidgeon's,
https://post.pidgeon.email. It signs in any address Pidgeon hosts, onpidgeon.emailor on a company's own verified domain. Then skip step 2. - For an address on a domain that runs its own issuer, find it: fetch
https://post.<domain>/.well-known/post.jsonand readidentity.issuer. - From there it is OpenID Connect: discovery at the issuer, the authorization
code flow with PKCE, and the ID token checked against the issuer’s keys.
Register with dynamic client registration, or use a Client ID Metadata
Document URL as your
client_id. Pass the address aslogin_hint. - Check that
emailis the address you asked about, then key the account onsub. Never key it onemail. An address can be deleted and later created again by somebody else;subis never given to anyone else, so the new holder arrives as a stranger rather than in the old one’s account.
The ID token is signed with EdDSA (Ed25519), the same key that signs the
domain’s mail. openid-client, jose, Auth.js and Authlib verify it. At
Pidgeon’s issuer, a client that registers "id_token_signed_response_alg": "ES256" gets ES256 instead, for an identity provider that verifies no EdDSA.
| Claim | What it says |
|---|---|
sub | A stable id for the address, never reused. Key accounts on this |
email | The address, with email_verified: true |
actor_type | agent, person or service |
on_behalf_of | The name of the workspace the address belongs to, when it has one |
owner_name | With the owner scope: the person who answers for the agent |
owner_email | With the owner scope: their email, when an owner chose to share it |
In your own code
Without an identity provider, the site's half is a library in both languages:
@pidgeon-ai/sign-in for Next.js and Express, and pidgeon.sign_in in the
Python SDK for Flask, FastAPI or Django. It adds the routes a site needs: an
agent's sign-in with no person present (its ID token, in a request signed with
its own key), a person's sign-in through the button, and a session for both.
import { pidgeonSignIn } from '@pidgeon-ai/sign-in';
import { routeHandlers } from '@pidgeon-ai/sign-in/next';
export const pidgeon = pidgeonSignIn({
clientId: process.env.PIDGEON_CLIENT_ID!,
url: 'https://shop.example',
secret: process.env.SESSION_SECRET!,
});
// app/api/auth/pidgeon/[action]/route.ts
export const { GET, POST } = routeHandlers(pidgeon);The two libraries make the same checks with the same messages, and read each other's sessions.
In the identity provider you already use
Add Pidgeon as an OpenID Connect provider, with
https://post.pidgeon.email as the issuer (discovery at
https://post.pidgeon.email/.well-known/openid-configuration). It is
configuration, not code.
First, register your app once, with your provider's callback URL. Keep the
client_id and the client_secret it answers with: the secret is shown once.
curl https://post.pidgeon.email/api/post/oidc/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "Your app",
"redirect_uris": ["YOUR_CALLBACK_URL"],
"token_endpoint_auth_method": "client_secret_post",
"id_token_signed_response_alg": "ES256"
}'
A provider that verifies EdDSA and needs no secret (Better Auth, Auth.js) can leave out the last two lines and register as a public client with PKCE.
Ask for the scopes openid email, and owner to learn the person who answers
for the agent. Then, provider by provider:
-
Auth0: Authentication → Enterprise → OpenID Connect. Discovery URL as above, communication channel Back Channel, the client ID and secret. The callback to register is
https://YOUR_AUTH0_DOMAIN/login/callback. Register withES256: Auth0 does not verify EdDSA. -
Clerk: a custom OpenID Connect provider, with the discovery URL, client ID and secret, and PKCE on. Create it first: Clerk shows the redirect URL to register on the connection's page. Register with
ES256. -
Supabase: Authentication → a custom OIDC provider, identifier
custom:pidgeon, the issuer, client ID and secret. The form shows the callback URL to register. Sign in withsupabase.auth.signInWithOAuth({ provider: 'custom:pidgeon' }). -
Better Auth: the
genericOAuthplugin.genericOAuth({ config: [ { providerId: 'pidgeon', discoveryUrl: 'https://post.pidgeon.email/.well-known/openid-configuration', clientId: process.env.PIDGEON_CLIENT_ID!, scopes: ['openid', 'email', 'owner'], pkce: true, tokenEndpointAuth: { method: 'none' }, }, ], });The callback is
https://your-app/api/auth/callback/pidgeon, and the sign-in starts withauthClient.signIn.social({ provider: 'pidgeon' })(before Better Auth 1.7:/api/auth/oauth2/callback/pidgeonandsignIn.oauth2). -
Auth.js: an
oidcprovider.{ id: 'pidgeon', name: 'Pidgeon', type: 'oidc', issuer: 'https://post.pidgeon.email', clientId: process.env.PIDGEON_CLIENT_ID, client: { token_endpoint_auth_method: 'none' }, checks: ['pkce', 'state'], authorization: { params: { scope: 'openid email owner' } }, }The callback is
https://your-app/api/auth/callback/pidgeon.
Whichever it is, key the account on sub, never on email, and map
actor_type and on_behalf_of into the user if your provider lets you: they
say it is an agent and who it works for.
To see both sides run, there is an app and an agent signing in to it, with
openid-client:
sign-in-with-address.