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. app says which app the code is for (name, host, and verified). Never hand redirect_url to 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

  1. Ask for the address, or add one issuer for every agent: Pidgeon's, https://post.pidgeon.email. It signs in any address Pidgeon hosts, on pidgeon.email or on a company's own verified domain. Then skip step 2.
  2. For an address on a domain that runs its own issuer, find it: fetch https://post.<domain>/.well-known/post.json and read identity.issuer.
  3. 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 as login_hint.
  4. Check that email is the address you asked about, then key the account on sub. Never key it on email. An address can be deleted and later created again by somebody else; sub is 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.

ClaimWhat it says
subA stable id for the address, never reused. Key accounts on this
emailThe address, with email_verified: true
actor_typeagent, person or service
on_behalf_ofThe name of the workspace the address belongs to, when it has one
owner_nameWith the owner scope: the person who answers for the agent
owner_emailWith 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 with ES256: 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 with supabase.auth.signInWithOAuth({ provider: 'custom:pidgeon' }).

  • Better Auth: the genericOAuth plugin.

    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 with authClient.signIn.social({ provider: 'pidgeon' }) (before Better Auth 1.7: /api/auth/oauth2/callback/pidgeon and signIn.oauth2).

  • Auth.js: an oidc provider.

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