01Why a fresh instance has no owner
A deployed instance is a public URL before it is anybody’s. There is no central account system to have registered with in advance — that is the whole design — so the first-run flow cannot be a sign-up.
It is a claim: a one-time proof that the person at the keyboard is the person who deployed it, made with a passkey and locked with a code that only the deployment’s own log has ever shown.
| Credential | On a fresh deployment |
|---|---|
| Emailed one-time code | No verified sending domain, so it cannot email you anything. |
| Cloudflare Access | No identity provider is configured, so Access answers nothing. |
| An existing account | Nobody is in memberships, so there is no account to sign into. |
| Passkey | Needs none of that — the browser already open on the page creates it, and it is the strongest of the lot. |
So claiming is “prove you have a browser pointed at this instance, and that you read its log”, and then write down ten recovery codes.
The claim code is what makes first-claimant-wins safe. First boot mints one and prints it once into the deployment’s log, alongside the bootstrap API key, and stores only its SHA-256. Reading a Worker’s log — wrangler tail, or the Worker’s Logs tab — is something only the person who deployed it can do, and it needs no email, no DNS and no identity provider, which is the same reason a passkey is the claim credential. Whoever else finds the URL cannot claim it.
The window is genuinely a window. Once an owner exists the claim path is closed and cannot be reopened, including by you: every /v1/setup/* route refuses with instance_claimed. A re-openable first-run path is an account takeover with extra steps. Two people opening /setup at the same moment also produce exactly one owner — the claim row is written first and alone, as a conditional insert, so the loser writes zero rows and is refused before any credential of theirs reaches the database. Done the other way round, the loser would be left holding a working passkey, which is a sign-in rather than a failed setup.
02Claim the instance
Two minutes, and one optional step that is worth taking first.
The safe version is the one where you decide who may claim it before the instance is reachable.
Set MS_OWNER_EMAIL before the first request reaches the instance and the claim completes only for that address — every other claimant is refused with This instance is reserved for a different address. It is a restriction, not a nomination: it creates nobody, sends nothing, and nothing will happen until you open /setup yourself. When it is set, the claim code is not asked for — two locks on one door buys nothing.
$ npx wrangler secret put MS_OWNER_EMAIL
It is deliberately absent from the Cloudflare deploy form, which stores answers as masked secrets and does not prefill defaults — an optional variable rendered there looks exactly like a credential the deployment cannot start without.
Twelve characters in three groups of four, drawn from an alphabet with the terminal-font confusables removed, printed once on first boot next to the bootstrap API key. Only its hash is stored, so this log line is the only place it exists.
# Your claim code — /setup asks for this before it will let anyone in:
K7QF-3MPX-R9TBYou give an address, the code, and a passkey. The code is checked on both legs of the flow — when the browser asks for a registration challenge and again when it submits the credential — because the first leg is what stops a passkey prompt that fails after you have already touched your key, and nothing but the challenge carries between them. It is compared as a hash, in constant time, after upper-casing and stripping spaces, so typing it back with the dashes is fine.
Shown once, single-use. Then /setup offers two more steps — adding a sending domain, and minting your first API key with a live test send — and both are skippable, because neither is required to have a working instance you can get back into.
03Choosing a sign-in method
Six ways in. Every door below is optional except the passkey, and the sign-in page renders each one only when it is genuinely open — a button that answers 501 costs the person trying to get in more time than no button at all.
The right number to have enabled is two: one you use, and one that still works when the first one’s dependencies are down.
| Door | What it gives you | What it costs you |
|---|---|---|
| Passkey | Always available, phishing-resistant, and the credential the claim itself is made with — it needs no email, no DNS and no identity provider, which is exactly why a brand-new deployment can create one. Discoverable, so signing in later types no address at all. | Bound to the hostname it was registered on. Move the instance to another host and every passkey enrolled on the old one stops working there; register two, and pin MS_PUBLIC_URL before you enrol either. |
| Recovery code | Ten, issued once when you claim, single-use and enforced in the UPDATE rather than in application code. This is the answer to “the laptop with the passkey is gone”, and the reason the product lets you remove your last passkey at all. | Shown exactly once. Store them somewhere that is not the laptop holding the passkey, or you have two copies of the same failure. |
| Cloudflare Access | Your identity provider decides who reaches the instance, and the assertion is verified properly — signature against your team’s published keys, iss against the team domain, aud against the application tag, exp and iat against the clock. | Appears only when MS_ACCESS_TEAM and MS_ACCESS_AUD are both set. Worth it if your team already runs Access; overhead if it would exist only for this. |
| Emailed one-time code | Nothing to configure and no password to leak: six digits, ten minutes, five attempts. The response never varies with the address — whether an account exists, whether it is suppressed, whether the code was logged rather than sent — because a sign-in form that answers that question is an enumeration endpoint. | Offered only once a sending domain is verified, and it sends through this deployment. If your sending is broken, so is this door — which is the whole argument for keeping a second one open. |
| OIDC | Central provisioning and de-provisioning, the right answer past a couple of people. Authorization Code with PKCE (S256), state and nonce in a short-lived signed cookie, JWKS-verified signatures, alg:none refused outright, and email_verified required — with claims read only after the signature verifies. | Three variables must all be set before the button renders, discovery and JWKS are cached for an hour, and auto-provisioning an unknown address needs a domain allowlist as well as the flag. A misconfigured redirect URI locks everyone out at once. |
| CLI device flow | For programs and for your own terminal: npx mailysend login prints a short code, you approve it in a browser that is already signed in, and the CLI receives a full-access key named after the client. | What it hands back is an API key, not a human session. Revoke it from Settings → Access like any other key, and do not treat a key as a sign-in method for a person. |
You can ask an instance which of these are open. GET /v1/instance is unauthenticated and is what every pre-auth screen renders from, so the page and the endpoint cannot disagree:
{ "object": "instance", "claimed": true, "mode": "single",
"claim": { "code_required": false, "reserved": false },
"auth": { "passkey": true, "access": false, "otp": true,
"device": true, "oidc": false },
"sending": { "ready": true, "verified_domains": 1, "last_error": null } }| Field | What it actually says |
|---|---|
| auth.access | Simply whether both Access variables are set. |
| auth.otp | Whether any domain is verified — a fact about the deployment, not about a person. |
| claim.reserved | That an address is required, without saying which. |
| POST /v1/auth/otp | Until auth.otp is true, answers 202 {"status":"unavailable"} rather than claiming to have sent mail. |
It carries no secrets and no per-address facts, by design.
04Closing the doors you are not using
Turn off what you are not using. Every enabled method is a way in, and an unused one is a way in that nobody is watching.
The one that matters most is already handled: the first-run claim closes permanently the moment an owner exists, so there is no window left open behind you.
| If you stopped using | Unset | What happens |
|---|---|---|
| That OIDC provider | the three OIDC variables | The button stops rendering, because the sign-in page is drawn from what is actually configured. |
| The Access application | MS_ACCESS_TEAM and MS_ACCESS_AUD | The Access door disappears from the sign-in page with it. |
Nothing stored is a working credential. Session tokens are random values whose SHA-256 is the row’s primary key, so a dump of sessions cannot be replayed as a cookie; API keys, recovery codes and the claim code are stored the same way. Cookies are HttpOnly, SameSite=Lax and Secure unless the instance is being served over plain HTTP on localhost, and a session lasts thirty days. This is also why nothing in the product can show you a key a second time.
There is no password store, and there will not be. A self-hosted email platform that invents one is adding the single credential most likely to be reused and leaked, to protect a dashboard that already sits behind whatever the operator put in front of it.
What just happened
The instance has an owner — you — the first-run path is permanently closed, and you can sign in by at least two independent routes. That last part is the one people skip and regret: a single sign-in method on infrastructure that sends its own login codes is a circular dependency waiting for a bad afternoon.