Every agent on Dead Simple has an inbox. As of today that inbox is also a login. Sign in with Dead Simple is an OpenID Connect provider at id.deadsimple.email where the subject of the token is an agent's inbox, so any app that accepts a generic OIDC provider can let an agent sign up and sign in with no custom code, and the agent can do it with the API key it already holds. Free on every plan, including Free.
This post covers why an inbox is the right anchor for an agent's identity, what the owner_verified claim gives a SaaS trying to stay honest about who its users are, and a complete worked example with an Auth.js app on one side and a Python agent on the other, using the payloads that actually cross the wire. At the end there is a short comparison with AgentMail's AgentID, which launched two days ago with the same idea.
Why the Inbox Is the Identity
An identity provider needs a subject that is stable, unique, and reachable. For people that settled on the email address long ago: it survives password changes and device swaps, it is unique by construction, and you can send it a message. Password resets, receipts and "new device" alerts all go to the address on file, so the address is the recovery channel and the audit trail, not just a label.
Agents have the same needs and, until recently, no good answer. An API key is a credential, not an identity; it identifies whoever holds it. A wallet address is stable but unreachable. A framework-issued agent id means nothing outside the framework. What an agent needs is what people already have: an address that persists across runs and hosts, that a third party can verify without trusting the agent's own claims, and that receives the reset email when things go wrong.
A Dead Simple inbox is exactly that, so we made it the OIDC subject. sub is the inbox id and never changes; email is the inbox address with email_verified always true, because the inbox is not something we checked once, it is the credential itself. An app that keys its users on sub gets one record per agent, with an address on it that it can actually email.
Everything else is plain OpenID Connect. Discovery at /.well-known/openid-configuration, a JWKS at /.well-known/jwks.json, ES256 id_tokens, authorization code flow with PKCE S256, dynamic client registration per RFC 7591, revocation per RFC 7009. If your auth library has a "custom OIDC provider" option, that option is the whole integration.
One Operator With 1,500 Agents Is Not 1,500 Users
Every SaaS that opens its doors to agents hits the same problem within a month. Agents sign up, lots of them: one developer's fleet of research workers, a growth tool's throwaway accounts, a single customer's legitimate assistant. From the app's side they all look like new users with new addresses, and the free tier, the referral programme and the abuse rules were all designed on the assumption that one signup is one person.
Sign in with Dead Simple puts two claims in every token to make that tractable. agent: true is always present, so an app can tell agent principals from human ones at a glance and route them to a different plan, a different rate limit, or a different onboarding. owner_verified says whether a verified human stands behind the account that owns the inbox. It is true for any account with a confirmed owner email and false for the unclaimed accounts that agents create for themselves through POST /v1/auth/agent-signup. An app can decide that unverified agents get a sandbox and verified ones get the real thing, in one if statement.
Apps that register a client can go further. The org scope adds the Dead Simple account id and plan to the token, so those 1,500 agents collapse to one operator in your database, and quota, billing and abuse decisions can apply at that level. The owner_email scope returns the operator's verified address, but only from /userinfo and never inside the id_token, so a JWT that lands in a log file cannot leak it. Open clients, which need no registration, get owner_verified but not the owner's identity. That is the line between "is someone accountable for this agent" and "who is it", and the second question should require the app to identify itself first.
A Worked Example, End to End
Two parties: app.example.com, a Next.js app using Auth.js, and a Python agent with a Dead Simple inbox named Research Bot. The app wants the agent to have an account; the agent wants to sign in without a browser. The payloads below are the real shapes from our test suite, with ids shortened.
The app: Auth.js
The app is an open client: its client_id is its own https origin, and the Auth.js callback path /api/auth/callback/deadsimple shares that origin, which is the only rule an open client has to follow. No registration, no secret; PKCE does the work.
import NextAuth from "next-auth" export const { handlers, auth } = NextAuth({ providers: [{ id: "deadsimple", name: "Dead Simple", type: "oidc", issuer: "https://id.deadsimple.email", wellKnown: "https://id.deadsimple.email/.well-known/openid-configuration", clientId: "https://app.example.com", client: { token_endpoint_auth_method: "none" }, checks: ["pkce", "state"], authorization: { params: { scope: "openid email profile" } }, profile(c) { return { id: c.sub, email: c.email, name: c.name } }, }], })
When anything hits the app's sign-in route, Auth.js builds the authorization URL from the discovery document. That URL is the only thing the agent needs from the app:
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
The agent: Python
A browser would follow that URL, land on a consent page, pick an inbox and approve. The agent has no browser, so it hands the URL to the one-call helper along with the inbox it wants to sign in as:
from deadsimple import DeadSimple client = DeadSimple(api_key="dse_...") inbox = client.inboxes.create(display_name="Research Bot", local_part="research") result = client.identity.sign_in(inbox.inbox_id, authorization_url) # follow=True by default: the SDK GETs redirect_to with a cookie jar and # follows redirects, so by now the app has exchanged the code. print(result["status"], result["final_url"]) # 200 https://app.example.com/dashboard
The same call is client.identity.signIn(inboxId, authorizationUrl) in the Node SDK and the sign_in_with_inbox tool in the MCP server, so an agent running inside Claude, Cursor or Grok Bot can be told "sign in to app.example.com with your research inbox" and do it.
What crossed the wire
The helper is POST /v1/inboxes/{inbox_id}/identity/sign-in. It validates the URL (it must be our issuer's /authorize, the client must resolve, PKCE must be present), mints the authorization code for that inbox, and returns the redirect the app is waiting for:
# 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_4b1e...&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." }
Without the helper, the raw equivalent is a GET on the authorization URL with Authorization: Bearer dse_..., inbox_id= appended, and Accept: application/json, which returns {redirect_to, code, state} instead of a 302. Either way the agent then GETs redirect_to, and Auth.js does the standard exchange:
# application/x-www-form-urlencoded grant_type=authorization_code &code=dse_idc0_4b1e... &code_verifier=<the verifier behind E9Melhoa...> &client_id=https%3A%2F%2Fapp.example.com &redirect_uri=https%3A%2F%2Fapp.example.com%2Fapi%2Fauth%2Fcallback%2Fdeadsimple # 200 { "access_token": "dse_idt_9d0c...", "token_type": "Bearer", "expires_in": 3600, "id_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6...", "scope": "openid email profile" }
No refresh_token, because open clients do not get one. Auth.js verifies the id_token against the JWKS, checks the nonce it sent, and reads these claims:
{
"iss": "https://id.deadsimple.email",
"aud": "https://app.example.com",
"sub": "6f1c2d3e-8a4b-4c5d-9e6f-0a1b2c3d4e5f",
"inbox_id": "6f1c2d3e-8a4b-4c5d-9e6f-0a1b2c3d4e5f",
"email": "research_9c4e1f2a@box1.deadsimple.email",
"email_verified": true,
"name": "Research Bot",
"preferred_username": "research_9c4e1f2a",
"agent": true,
"owner_verified": true,
"nonce": "n-1",
"auth_time": 1789084800, "iat": 1789084800, "exp": 1789088400,
"at_hash": "..."
}
The agent now has a session on app.example.com. Its user record is keyed on the inbox id, its email is an address the app can write to, and agent: true is there for the app to act on. From the agent's side it was one SDK call. The inbox's own record of it is at GET /v1/inboxes/{inbox_id}/identity/connections, which now lists app.example.com with a sign_in_count of 1.
Adding the owner: a registered client
Suppose app.example.com wants the accountability story from earlier. It registers once, authenticated with its own Dead Simple API key:
# Authorization: Bearer dse_... { "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" } # 201: client_id dse_idc_..., client_secret dse_ics_... (shown once)
It swaps the origin for dse_idc_... as clientId, adds the secret, and asks for owner_email org in the scope. The token exchange now uses HTTP Basic with the client credentials and returns a rotating dse_idr_ refresh token. The id_token grows "org": "<account id>" and "plan": "free". And a call to /userinfo with the access token returns the one claim that never goes in a JWT:
# Authorization: Bearer dse_idt_9d0c... { "sub": "6f1c2d3e-8a4b-4c5d-9e6f-0a1b2c3d4e5f", "inbox_id": "6f1c2d3e-8a4b-4c5d-9e6f-0a1b2c3d4e5f", "agent": true, "email": "research_9c4e1f2a@box1.deadsimple.email", "email_verified": true, "name": "Research Bot", "owner_verified": true, "owner_email": "owner@example.com", "org": "0b7d4a6e-2c19-4f8e-b3a1-5d6e7f8a9b0c", "plan": "free" }
For an inbox on an unclaimed agent-signup account, that same response comes back with owner_verified: false and owner_email: null. The sign-in still succeeds; the app just knows exactly what it is dealing with.
When the Agent Should Not Hold an API Key
The API key path is the simplest and, for most agents, the right one. But a full key grants mailbox access, and some deployments should not give the signing-in process that much. For those, enrol a P-256 public key on the inbox with POST /v1/inboxes/{inbox_id}/identity/keys, keep the private half on the agent, and sign a short ES256 assertion at sign-in time (sub is the inbox id, aud is the issuer, exp at most ten minutes out, jti accepted exactly once). Post it to /authorize as assertion= and the code comes back the same way. Deleting the key revokes that agent without rotating anything else. The product page has the openssl commands and the signing code.
How This Compares to AgentID
AgentMail launched AgentID on September 9 with the same core idea: an agent's email is its identity, and that identity should work as a standard OIDC login. We think they are right, and we built to the same standards on purpose. Both are free. Both do discovery, JWKS, PKCE and RFC 7591 registration, and both plug into the generic OIDC option of whatever auth library an app already uses, so choosing one never locks an app out of the other.
Three things are specific to Sign in with Dead Simple. First, an agent can sign in with just its API key and an inbox_id; there is no key generation or enrolment before the first login, though the signed-assertion path is there for agents that want it. Second, the one-call helper: sign_in_with_inbox in the MCP server and identity.sign_in in the SDKs take the app's authorization URL and return the redirect to follow, so the agent side is a single line. Third, the browser consent flow at app.deadsimple.email lets a human sign in to an app with an agent's inbox too, which is how you test an integration and how an operator can use an agent's identity on a service without handing that service a key.
Where to Start
If you run an app: add a custom OIDC provider pointing at the discovery URL, use your origin as the client_id, and you are accepting agent sign-ins. Register a client when you want org and owner_email. Snippets for Auth.js, Better Auth, Clerk, Auth0 and Supabase are on the product page. If you run an agent, it already has everything it needs: hand it the app's sign-in URL and call sign_in. Five inboxes are free, no card, and identity is included on every plan.