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 closed for good the moment it succeeds. The proof is a claim code the instance prints to its own log, which only whoever deployed it can read.
| 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 can read this instance’s log, with a browser pointed at it”, and then write down ten recovery codes.
The claim code is always asked for. Every boot of an unclaimed instance makes sure it has a code: the first one mints it beside the bootstrap API key, prints it once and keeps only its SHA-256. /setup will not start a passkey ceremony without it. Reading a Worker’s log needs no email, no DNS and no identity provider, which is the same reason a passkey is the claim credential, and nobody who merely found the URL can do it.
| What you set | What /setup asks for |
|---|---|
| Nothing | The claim code, an address and a passkey. |
| MS_OWNER_EMAIL | The same, and the address must be that one. It creates nobody and sends nothing. With OIDC or Access set up, that address can also claim by signing in, since the provider verified it. |
There is no switch that turns the code off. An address in a variable is something anybody can type, and an operator’s address is usually public, so it narrows the claim and never stands in for the code.
The window is genuinely a window. Once an owner exists the claim path is closed and cannot be reopened, including by you: both /v1/setup/claim legs refuse with instance_claimed. The exception is the break-glass claim/cli, which keeps working because it asks for write access to the database itself. While the window is open, no sign-in door enrols anybody either: the emailed code never creates an account, and OIDC or Access claim only for the address in MS_OWNER_EMAIL, which their provider has verified. 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 the one thing you need is the deploy log.
The claim code is printed once, beside the bootstrap API key, and /setup asks for it before anything else. Read it while the log is still in front of you.
On Cloudflare it is in wrangler tail, or the Worker’s Logs tab, from the first request the instance answered. On Node it is in the process output.
You enter the code, give an address and create a passkey, and the browser already open on the page is the whole credential — which is why this works on a deployment with no verified sending domain and no identity provider.
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.
If you know which address will claim it, set that as well. MS_OWNER_EMAIL is a second condition on top of the code, for the deployment whose URL is public before its operator arrives — a custom domain pointed at the Worker in advance, a deploy run by somebody else. Set it before the first request reaches the instance.
- Minted on the first boot of an unclaimed instance, and /setup demands it. Twelve characters in three groups of four, from an alphabet with the terminal-font confusables removed.
- Only its SHA-256 is stored, so that log line is the only place the code ever exists.
- 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.
- Compared as a hash, in constant time, after upper-casing and stripping spaces, so typing it back with the dashes is fine.
- Narrows the claim to one address. Every other claimant is refused with This instance is reserved for a different address, but only after the code has matched, so a caller without it learns nothing about which address is expected.
- A restriction, not a nomination: it creates nobody, sends nothing, and nothing happens until you open /setup yourself.
- Set it with 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.
- Never a replacement for the code: the address is whatever the caller typed.
# This instance is unclaimed. Your claim code — /setup asks for it:
K7QF-3MPX-R9TB03Choosing 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.code_required | True while the instance is unclaimed, because every unclaimed instance asks for the code. |
| 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.