How it works

Three steps, all of them standard.

The relying party does what it does for any OIDC provider. The only thing that changes is who is on the other end of the redirect.

1

Your app redirects to /authorize

Auth.js, Better Auth, Clerk, Auth0 or your own code builds the usual authorization URL from the discovery document: response_type=code, a PKCE challenge, a state, and the scopes you want.

2

The agent authenticates as its inbox

Headless: the agent sends its API key and inbox_id, or an ES256 assertion signed by an enrolled key, and gets the code straight back. In a browser: the owner picks an inbox on the consent page.

3

Your app exchanges the code

POST /token returns an ES256 id_token you verify against the JWKS, plus an access token for /userinfo. sub is the inbox id and never changes. The agent is signed in.

what your app receives in the id_token
{
  "iss": "https://id.deadsimple.email",
  "aud": "https://app.example.com",
  "sub": "6f1c2d3e-8a4b-4c5d-9e6f-0a1b2c3d4e5f",   // the inbox_id, stable for life
  "inbox_id": "6f1c2d3e-8a4b-4c5d-9e6f-0a1b2c3d4e5f",
  "email": "research_9c4e1f2a@box1.deadsimple.email",
  "email_verified": true,
  "name": "Research Bot",
  "agent": true,
  "owner_verified": true,
  "nonce": "n-1",
  "iat": 1789084800, "exp": 1789088400, "auth_time": 1789084800
}

For relying parties

Point your auth library at the discovery document and you are done. Every snippet below uses the generic OIDC option of the library in question, so there is nothing Dead Simple specific to install. Two things to decide up front:

  • Open or registered client. An open client is any https origin you control: client_id is that origin, every redirect_uri must share it, there is no secret, and PKCE carries the security. Open clients can request openid email profile. A registered client (one POST /register) gets a dse_idc_ id and a dse_ics_ secret, and can also request owner_email, org and offline_access. See the comparison below.
  • Which claims you key on. Use sub as the user id. It is the inbox id and it does not change if the agent is renamed. The email claim is the inbox address and is always verified, because the inbox is the credential.
discovery
# Everything an OIDC library needs is here
curl https://id.deadsimple.email/.well-known/openid-configuration

# Issuer                 https://id.deadsimple.email
# Authorization          https://id.deadsimple.email/authorize   (GET or POST)
# Token                  https://id.deadsimple.email/token
# Userinfo               https://id.deadsimple.email/userinfo
# JWKS                   https://id.deadsimple.email/.well-known/jwks.json
# Registration           https://id.deadsimple.email/register    (RFC 7591)
# Revocation             https://id.deadsimple.email/revoke      (RFC 7009)
# id_token alg           ES256      PKCE   S256 only
# token_endpoint_auth    none, client_secret_basic, client_secret_post

Auth.js (NextAuth v5)

Auth.js accepts a custom provider object with type: "oidc". With issuer set it reads the discovery document itself; wellKnown is shown for clarity and for v4. The callback path Auth.js uses is /api/auth/callback/<id>, which shares your origin, so an open client works with no registration.

auth.ts
import NextAuth from "next-auth"

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    {
      id: "deadsimple",
      name: "Dead Simple",
      type: "oidc",
      issuer: "https://id.deadsimple.email",
      wellKnown: "https://id.deadsimple.email/.well-known/openid-configuration",

      // Open client: your https origin is the client_id, no secret.
      clientId: "https://app.example.com",
      client: { token_endpoint_auth_method: "none" },

      // Registered client instead? Use the ids from /register:
      // clientId: "dse_idc_...", clientSecret: "dse_ics_...",
      // and add "owner_email org" to the scope below.

      checks: ["pkce", "state"],
      authorization: { params: { scope: "openid email profile" } },
      profile(claims) {
        return { id: claims.sub, email: claims.email, name: claims.name }
      },
    },
  ],
})

Better Auth

Better Auth's genericOAuth plugin takes a discoveryUrl and does the rest. Its callback path is /api/auth/oauth2/callback/<providerId>. The snippet uses a registered client so it can ask for owner_email; drop clientSecret and the two extra scopes for an open client.

auth.ts
import { betterAuth } from "better-auth"
import { genericOAuth } from "better-auth/plugins"

export const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        {
          providerId: "deadsimple",
          discoveryUrl: "https://id.deadsimple.email/.well-known/openid-configuration",
          clientId: process.env.DSE_ID_CLIENT_ID,        // dse_idc_...
          clientSecret: process.env.DSE_ID_CLIENT_SECRET, // dse_ics_...
          scopes: ["openid", "email", "profile", "owner_email", "org"],
          pkce: true,
        },
      ],
    }),
  ],
})

// client side
await authClient.signIn.oauth2({ providerId: "deadsimple", callbackURL: "/dashboard" })

Clerk

In the Clerk Dashboard open SSO Connections, add a connection for all users, and choose Custom OpenID Connect (OIDC) provider. Clerk requires a client secret, so register a client first and paste Clerk's redirect URL into redirect_uris.

Clerk fieldValue
NameDead Simple
Keydeadsimple
Discovery Endpointhttps://id.deadsimple.email/.well-known/openid-configuration
Client ID / Client Secretdse_idc_... / dse_ics_... from /register
Scopesopenid email profile (add owner_email org if you want them)
Attribute mappingUser ID sub, Email email, Name name

Supabase

Supabase Auth does not act as a relying party for arbitrary OIDC issuers: signInWithOAuth is limited to its built-in provider list and signInWithIdToken accepts a fixed set of issuers. The honest integration is to run the OIDC flow yourself (or with one of the libraries above), then verify the id_token against our JWKS in an Edge Function and create or look up the Supabase user keyed on sub.

supabase/functions/agent-login/index.ts
import { createRemoteJWKSet, jwtVerify } from "npm:jose@5"
import { createClient } from "npm:@supabase/supabase-js@2"

const JWKS = createRemoteJWKSet(new URL("https://id.deadsimple.email/.well-known/jwks.json"))

Deno.serve(async (req) => {
  const { id_token } = await req.json()
  const { payload } = await jwtVerify(id_token, JWKS, {
    issuer: "https://id.deadsimple.email",
    audience: "https://app.example.com",   // your client_id
    algorithms: ["ES256"],
  })

  // payload.sub is the inbox_id; payload.email is the agent's address.
  const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!)
  const { data } = await admin.auth.admin.createUser({
    email: payload.email as string,
    email_confirm: true,
    user_metadata: { inbox_id: payload.sub, agent: true, owner_verified: payload.owner_verified },
  })
  // Then issue a session for data.user (magic link via generateLink, or your own JWT).
  return Response.json({ user_id: data.user?.id })
})

Auth0

Use Auth0's OpenID Connect connection type (Authentication, Enterprise, OpenID Connect), which takes the issuer and discovers the endpoints. A Custom Social Connection also works if you prefer to fill in the authorize and token URLs by hand. Either way, register a client and add https://<tenant>.auth0.com/login/callback to its redirect_uris. Via the Management API:

POST /api/v2/connections
{
  "name": "deadsimple",
  "strategy": "oidc",
  "options": {
    "type": "back_channel",
    "discovery_url": "https://id.deadsimple.email/.well-known/openid-configuration",
    "client_id": "dse_idc_...",
    "client_secret": "dse_ics_...",
    "scope": "openid email profile owner_email org"
  }
}

Registering a client

One call, authenticated with a Dead Simple API key (trial keys from agent self-signup cannot register clients). The secret is shown once. The same thing is available with the API envelope at POST /v1/identity/clients, and in the dashboard.

terminal
curl -X POST https://id.deadsimple.email/register \
  -H "Authorization: Bearer dse_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Acme SaaS",
    "client_uri": "https://acme.example",
    "redirect_uris": ["https://acme.example/auth/callback"],
    "scope": "openid email profile owner_email org offline_access",
    "token_endpoint_auth_method": "client_secret_basic"
  }'

# 201
{
  "client_id": "dse_idc_3f9a1c77b2e04d5a6b8c",
  "client_secret": "dse_ics_...",          // shown once
  "client_secret_expires_at": 0,
  "redirect_uris": ["https://acme.example/auth/callback"],
  "scope": "openid email profile owner_email org offline_access",
  "token_endpoint_auth_method": "client_secret_basic",
  "grant_types": ["authorization_code", "refresh_token"],
  "issuer": "https://id.deadsimple.email",
  "discovery_url": "https://id.deadsimple.email/.well-known/openid-configuration"
}

For agents

An agent has no browser and no patience for a key ceremony. So /authorize accepts the credential the agent already holds. There are three ways in, in order of simplicity.

1. The API key it already has

Send the API key as a Bearer token and say which inbox is signing in with inbox_id= (or login_hint= with the inbox address; if the account has exactly one inbox you can omit both). Add Accept: application/json and the code comes back as JSON instead of a 302.

terminal
# The authorization URL is whatever the app's "Sign in with Dead Simple"
# button points at. Append inbox_id and send the key.
curl -s "https://id.deadsimple.email/authorize?response_type=code\
&client_id=https%3A%2F%2Fapp.example.com\
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fapi%2Fauth%2Fcallback%2Fdeadsimple\
&scope=openid%20email%20profile&state=xyz&nonce=n-1\
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM\
&code_challenge_method=S256\
&inbox_id=6f1c2d3e-8a4b-4c5d-9e6f-0a1b2c3d4e5f" \
  -H "Authorization: Bearer dse_your_api_key" \
  -H "Accept: application/json"

# 200
{
  "redirect_to": "https://app.example.com/api/auth/callback/deadsimple?code=dse_idc0_...&state=xyz",
  "code": "dse_idc0_...",
  "state": "xyz"
}

# Finish: GET redirect_to with the app's cookies, following redirects.
# The app exchanges the code at /token and the agent is signed in.

Without the Accept header the same request returns a 302 to redirect_uri with code and state, which is what a browser-shaped client wants. A request with no credential at all and a JSON Accept gets 401 login_required rather than a redirect, so a headless agent never ends up parked on a consent page.

The one-call helper

Building the request by hand is fine for curl. In code, hand the authorization URL to the helper and it returns the URL to follow. This is POST /v1/inboxes/{inbox_id}/identity/sign-in, and it is exposed in the SDKs and as an MCP tool.

agent.py
from deadsimple import DeadSimple

client = DeadSimple(api_key="dse_...")

# authorization_url is the app's sign-in link (its /authorize URL with PKCE etc.)
result = client.identity.sign_in(inbox_id, authorization_url)
# follow=True (the default) GETs redirect_to and follows redirects,
# so the app has already exchanged the code by the time this returns.
print(result["redirect_to"], result["final_url"], result["status"])

# Pass follow=False to get redirect_to only and drive the HTTP client yourself.
agent.ts
import { DeadSimple } from "@deadsimple/email"

const client = new DeadSimple({ apiKey: "dse_..." })
const result = await client.identity.signIn(inboxId, authorizationUrl)
// result.redirectTo, plus the final URL and status once followed
MCP
# Any MCP client connected to the Dead Simple server has this tool:
sign_in_with_inbox(inbox_id, authorization_url)

# In Claude, Cursor or Grok Bot, that means:
"Sign in to app.example.com with your research inbox."

The raw response from the helper, for when you call it directly:

POST /v1/inboxes/{inbox_id}/identity/sign-in
# request
{ "authorization_url": "https://id.deadsimple.email/authorize?response_type=code&client_id=..." }

# 201
{
  "redirect_to": "https://app.example.com/api/auth/callback/deadsimple?code=dse_idc0_...&state=xyz",
  "client_id": "https://app.example.com",
  "client_name": "app.example.com",
  "scope": "openid email profile",
  "expires_in": 300,
  "next_step": "GET redirect_to (follow redirects). The app exchanges the code and signs the agent in."
}

2. A signed assertion (no API key on the agent)

For agents that should not hold mailbox access at all, enrol a P-256 public key on the inbox once, keep the private key on the agent, and sign a short-lived ES256 JWS at sign-in time. The key never leaves the agent, and deleting it from the inbox revokes the agent's ability to sign in without touching any API key.

terminal
# Generate a P-256 keypair on the agent
openssl ecparam -name prime256v1 -genkey -noout -out agent.pem
openssl ec -in agent.pem -pubout -out agent.pub.pem

# Enrol the public half on the inbox (PEM or JWK both accepted)
curl -X POST https://api.deadsimple.email/v1/inboxes/$INBOX_ID/identity/keys \
  -H "Authorization: Bearer dse_your_api_key" \
  -H "Content-Type: application/json" \
  -d "{\"public_key\": $(jq -Rs . < agent.pub.pem), \"name\": \"worker-7\"}"

# 201
{ "kid": "hZ1xQ2...", "inbox_id": "6f1c...", "name": "worker-7",
  "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "kid": "hZ1xQ2..." },
  "created_at": "2026-09-11T14:02:11Z", "last_used_at": null }
sign_in.py
import time, uuid, jwt, requests

now = int(time.time())
assertion = jwt.encode(
    {
        "iss": INBOX_ID, "sub": INBOX_ID,
        "aud": "https://id.deadsimple.email",
        "iat": now, "exp": now + 120,        # at most 10 minutes
        "jti": str(uuid.uuid4()),          # good exactly once
        # optional bindings; if present they must match the request
        "client_id": "https://app.example.com",
        "code_challenge": params["code_challenge"],
    },
    open("agent.pem").read(), algorithm="ES256", headers={"kid": KID},
)

r = requests.post("https://id.deadsimple.email/authorize",
                  data={**params, "assertion": assertion},
                  headers={"Accept": "application/json"})
redirect_to = r.json()["redirect_to"]   # then GET it, following redirects

Keys are managed at GET/POST/DELETE /v1/inboxes/{inbox_id}/identity/keys[/{kid}], and in the SDKs as enroll_key, list_keys and delete_key (enrollKey, listKeys, deleteKey in Node). The list shows last_used_at per key, so an idle key is easy to spot and remove.

3. A human, in a browser

When /authorize gets no credential from a browser, it redirects to https://app.deadsimple.email/id/consent. The account owner signs in to the dashboard, picks one of their inboxes, and approves. The relying party gets exactly the same code, token and claims as in the headless paths. This is how a person tests an integration, and how a human operator can use an agent's identity on an app without handing that app an API key.

Seeing where an inbox has signed in

GET /v1/inboxes/{inbox_id}/identity/connections (SDK: connections) lists every relying party the inbox has signed in to, with the scope granted, first and last sign-in, and a count.

GET /v1/inboxes/{inbox_id}/identity/connections
{
  "connections": [
    {
      "client_id": "https://app.example.com",
      "client_name": "app.example.com",
      "client_uri": "https://app.example.com",
      "scope": "openid email profile",
      "first_sign_in_at": "2026-09-11T14:05:40Z",
      "last_sign_in_at": "2026-09-11T14:05:40Z",
      "sign_in_count": 1
    }
  ],
  "total": 1
}

Claims

What the tokens say.

Scopes gate which claims appear. openid is required; owner_email, org and offline_access need a registered client.

ClaimScopeValueWhere
subopenidThe inbox_id. Stable for the life of the inbox; use it as the user id.id_token, userinfo
inbox_idopenidSame value as sub, named for clarity.id_token, userinfo
agentopenidAlways true. Lets an app distinguish agent principals from human ones at a glance.id_token, userinfo
owner_verifiedopenidtrue when a verified human owns the account. false for unclaimed agent-signup (trial) accounts.id_token, userinfo
emailemailThe inbox address, for example research_9c4e1f2a@box1.deadsimple.email or an address on your own domain.id_token, userinfo
email_verifiedemailAlways true. The inbox is the credential.id_token, userinfo
nameprofileThe inbox display name (falls back to the local part).id_token, userinfo
preferred_usernameprofileThe local part of the address.id_token, userinfo
orgorg (registered)The Dead Simple account id that owns the inbox. Group every agent from one operator under it.id_token, userinfo
planorg (registered)The account's plan: trial, free, hobby, pro, scale, enterprise.id_token, userinfo
owner_emailowner_email (registered)The verified email of the human who owns the account, or null when owner_verified is false.userinfo only
nonce, auth_time, at_hashopenidStandard OIDC. nonce echoes the request; at_hash binds the id_token to the access token.id_token

Clients

Open or registered.

Start open. Register when you need the owner behind the agent or long-lived sessions.

Open clientRegistered client
SetupNone. client_id is your https origin.One POST /register with a Dead Simple API key.
client_idhttps://app.example.comdse_idc_...
SecretNone (PKCE only)dse_ics_..., shown once, or none if you ask for a public client
redirect_uri ruleMust share the origin of client_idMust be one of the registered URIs (https, loopback http, or app scheme)
Scopesopenid email profilePlus owner_email, org, offline_access
Token endpoint authnoneclient_secret_basic, client_secret_post, or none
Refresh tokensNoYes, 30 days, rotated on every use
Sees the human ownerNoYes, via /userinfo with owner_email
Can group an operator's agentsNoYes, via org
ManageNothing to manageGET/POST/DELETE /v1/identity/clients[/{client_id}], or the dashboard

Security notes

  • PKCE is required. Every authorization request must carry a code_challenge with method S256. There is no implicit flow and no plain challenge.
  • Codes are single use and short. An authorization code lives five minutes and is deleted before it is validated, so a failed exchange burns it and a replay cannot double-spend.
  • Unverified redirects are never followed. A client_id that does not resolve, or a redirect_uri that is not same-origin (open) or not registered (registered), gets a 400 with no redirect at all.
  • owner_email is userinfo only. The owner's address is never placed in the id_token, so a logged or leaked JWT cannot expose it. It is served only to registered clients, only over a live access token.
  • Assertions are replay-guarded. Each jti is accepted exactly once, lifetimes are capped at ten minutes, and any client_id, redirect_uri, code_challenge or nonce inside the assertion must match the request it is presented with. Only enrolled P-256 keys are accepted; a JWK with a private component is rejected at enrolment.
  • Tokens are hashed at rest and revocable. Access and refresh tokens are stored as SHA-256 hashes. POST /revoke (RFC 7009) kills either kind immediately. Refresh tokens rotate on every use, and a reused refresh token is rejected.
  • Lifetimes. id_token 1 hour, access token 1 hour, refresh token 30 days, authorization code 5 minutes, browser consent request 10 minutes.
  • Client secrets are one-way. Registered client secrets are shown once at registration and stored hashed. Registration requires a full Dead Simple API key; trial keys and MCP OAuth tokens cannot register clients, so an anonymous party cannot mint a client that requests owner data.
  • An API key can only sign in its own inboxes. A key from account A presented with an inbox_id from account B gets a 404.

FAQ

Questions people ask first.

Yes. The issuer is https://id.deadsimple.email with discovery at /.well-known/openid-configuration, a JWKS at /.well-known/jwks.json, ES256-signed id_tokens, the authorization-code flow with PKCE S256, RFC 7591 dynamic client registration at /register, and RFC 7009 revocation at /revoke. Any library that accepts a generic OIDC provider works without custom code.

No. The simplest path is the API key the agent already has: send Authorization: Bearer dse_... plus inbox_id= to /authorize and the code is minted immediately. Enrolling a P-256 key and signing an assertion is available for agents that should not hold a full API key, but it is optional.

A stable sub (the inbox_id), the inbox email address with email_verified true, a display name, inbox_id, agent: true, and owner_verified, which is true when a verified human owns the account and false for unclaimed agent-signup accounts. Registered clients can additionally request org (the account id and plan) and owner_email, which is only ever returned from /userinfo, never inside the id_token.

Not for the basic scopes. An open client uses its own https origin as client_id, and every redirect_uri must share that origin. Open clients can request openid, email and profile with no secret and PKCE. Register at /register with a Dead Simple API key to get a client_id and client_secret, which unlocks owner_email, org, and offline_access refresh tokens.

Yes. When /authorize receives no credential from a browser, it redirects to a consent page on app.deadsimple.email where the account owner picks one of their inboxes and approves. The relying party sees the same claims either way.

Nothing. It is included on every plan, including Free, with no per-login or per-client charges. Token lifetimes are one hour for id and access tokens, 30 days for rotating refresh tokens, and five minutes for single-use authorization codes.

Give your agent a login

Create an inbox, point your auth library at the discovery URL, and sign in. Free on every plan.

Get Started Free Read the worked example API Reference