# auth bootstrap (login, before anyone is signed in)

> Under deny-all, login faces a paradox: it must read a user row *before* anyone is authenticated. `app.AuthBootstrap` resolves it. You name a `[Role]`, and the engine runs your `[AuthMethod]` functions as an ephemeral principal bearing that role — with no `User` row and no grant row behind it, so a `RoleGrant.Any(g => g.User == user …)` policy is TRUE for it without any grant existing — for exactly the functions you wired, and no others. It mints no special access: what that principal may touch is your ordinary `security { }` grants for the role. The bootstrap path is therefore not a hole you have to remember; it is leashed by rules you can read on the entity.

<!-- id: security-auth-bootstrap · area: security · stability: stable · html: https://osysharp.com/reference/security/auth-bootstrap/ -->

## Summary        {#summary}
With [deny-all](https://osysharp.com/reference/security/secure-by-default/) in force, an entity you have not granted is denied to everyone — and that
breaks login, which must reach a user row *before* anyone is signed in. **`app.AuthBootstrap`** resolves the paradox by
declaring the sign-in flow, so the engine can run it under an identity you defined:

```osy title="the whole wiring" test app=security-auth-bootstrap
app.AuthBootstrap = new AuthBootstrap {
  Role          = AppRole.Authenticator,   // the role the ephemeral principal bears
  Login         = Login,                   // an [AuthMethod] — run as that principal, when nobody is signed in
  Signup        = Signup,                  // optional
  PasswordReset = StartReset,              // optional
  LoginPage     = LoginPage,               // optional — where a signed-out visitor is sent
};
```

The ephemeral principal has **a role and no user**. It is minted by the engine, never by your code, and only for the
methods wired here. It gets **no special access**: it may touch exactly what your `security { }` blocks grant that
role — see [the leash](https://osysharp.com/reference/security/auth-method/).

⚠ **That role can read every password hash.** It has to: signing in means checking a password against its hash. So it
belongs to the platform's sign-in principal alone and is **never given to a person** — see
[below](#never-to-a-person).

## Signature      {#signature}
```osy syntax
app.AuthBootstrap = new AuthBootstrap {
  Role          = <RoleEnum>.<Member>,   // REQUIRED — a member of the app's [Role] enum
  Login         = <function>,            // REQUIRED — an [AuthMethod] function
  Signup        = <function>,            // optional  — an [AuthMethod] function
  PasswordResetRequest = <function>,     // optional  — an [AuthMethod]: mail a reset link
  PasswordReset = <function>,            // optional  — an [AuthMethod]: a new password from the link
  PasswordChange = <function>,           // optional  — an [AuthMethod]: signed in, with the current password
  EmailChange   = <function>,            // optional  — an [AuthMethod]: signed in, a new address
  EmailVerifyRequest = <function>,       // optional  — an [AuthMethod]: mail an address-verification link
  EmailVerify   = <function>,            // optional  — an [AuthMethod]: open an address-verification link
  LoginPage     = <component>,           // optional  — a routed [Route] component
  OAuthSignup   = <function>,            // optional  — an [AuthMethod]: finish a new OAuth sign-in
  OAuthLink     = <function>,            // optional  — an [AuthMethod]: link a provider to an existing account
  AcceptInvite  = <function>,            // optional  — an [AuthMethod]: accept an invite (token + credentials)
  SecondFactorSignIn        = <function>, // optional — an [AuthMethod]: a challenge + a code → the ticket
  SecondFactorEnable        = <function>, // optional — an [AuthMethod]: turn a second factor on
  SecondFactorDisable       = <function>, // optional — an [AuthMethod]: turn it off (password AND a code)
  SecondFactorRecoveryCodes = <function>, // optional — an [AuthMethod]: a new set of recovery codes
  OAuthSignIn   = <function>,            // optional  — an [AuthMethod]: EVERY provider sign-in, decided by the app
  OAuthUnlink   = <function>,            // optional  — an [AuthMethod]: stop a provider signing in to an account
  EmailVerifyCode = <function>,          // optional  — an [AuthMethod]: prove an address by its mailed six-digit code
  PhoneVerify   = <function>,            // optional  — an [AuthMethod]: prove a phone number by a texted code
  PhoneSignIn   = <function>,            // optional  — an [AuthMethod]: sign in with a code texted to a proven number
  SmsSecondStep = <function>,            // optional  — an [AuthMethod]: a texted code as an account's chosen second step
  EmailDeliveryProblem = <function>,     // optional  — an [AuthMethod]: whether mail to an address bounced
  MachineSignIn = <function>,            // optional  — an [AuthMethod]: a program trades a refresh token for a ticket
  PasskeyChallenge = <function>,         // optional  — an [AuthMethod]: a challenge for a passkey ceremony
  PasskeySignIn = <function>,            // optional  — an [AuthMethod]: a passkey's answer → the ticket
  PasskeyAdd    = <function>,            // optional  — an [AuthMethod]: add a passkey to an account
  PasskeyRemove = <function>,            // optional  — an [AuthMethod]: remove one (never the last way in)
  PasswordRemove = <function>,           // optional  — an [AuthMethod]: turn password sign-in off
  EmailChangeCancel = <function>,        // optional  — an [AuthMethod]: the old address's "this wasn't me" link
  CiSignIn      = <function>,            // optional  — an [AuthMethod]: a CI job signs in by its CI's verified token
};
```

All thirty members, and nothing else — an unknown member is a compile error. `Role` and `Login` are **required**; the
rest are optional. `app.AuthBootstrap` is a **singleton**: declaring it twice is a compile error. Every named function,
the `Role` member and `LoginPage` are resolved and checked at compile time, so a typo is a build failure and never a
door left open at runtime. The two `OAuth*` slots wire your own OAuth-completion pages — see [OAuth account completion — Security.VerifyPendingOAuthEmail / LinkOAuthFromPending](https://osysharp.com/reference/security/oauth-completion/)
for what they do — and `AcceptInvite` wires an invite-acceptance flow ([below](#accept-invite)).

## Description    {#description}

### Why can't `Login` read the `User` row?   {#paradox}
Deny-all is the right posture: an entity nobody granted is readable by nobody. But `Login` has to read the `User` row
to check a password, and at that moment there is **no signed-in user to grant anything to**. Simply marking `Login` as
"anonymous" would not help — an anonymous caller is granted nothing, so the read inside it would still be denied.

The missing piece is not reachability, it is **identity**. The engine needs an identity to run the sign-in flow *as* —
one you have described, and can grant to, like any other. That identity is the ephemeral principal.

### Who does the engine run `Login` as? The ephemeral principal   {#ephemeral-principal}
When a caller invokes one of the declared methods, the engine runs it as a principal that:

- **bears the one `[Role]` you named** (`Role = AppRole.Authenticator`), and
- **has no user behind it** — there is no `User` row, and no grant row, for the authenticator. It is not an account;
  it is an identity that exists for the duration of the call.

Two properties follow, and together they are what make the scary corner safe:

- **Only a declared method is routed.** Any other function gets the caller's ordinary context — for an anonymous
  caller, deny-all — and never the role. The method list is the one in `app.AuthBootstrap`, not a marker a stray
  function could wear (that is the other half of [the both-ways check](https://osysharp.com/reference/security/auth-method/)). The routing is the same
  whether or not the caller is already signed in: a signed-in user who calls `Signup` for a colleague needs the auth
  role exactly as a stranger does, and a check on "not signed in" would stop nobody — signing out defeats it.
- **Least privilege, by your own grants.** The principal may read and write exactly what your `security { }` blocks
  grant its role. Grant it nothing and it can do nothing. There is no source-level construct that mints a
  role-bearing principal, so the elevation seam lives inside the engine, where your code cannot reach it.

### The auth role reads every password hash — never give it to a person   {#never-to-a-person}
The role you name in `Role =` (conventionally `Authenticator`) is the one your credential is unmasked for —
`deny read PasswordHash when !IsAuthenticator;` hides the hash from everyone *except* that role, because sign-in has
to read it to check a password. So whoever holds the role can read **every** password hash in the app.

The sign-in flow holds it without a grant: the platform gives it to its own principal, which has no account. No
person — and no machine account — ever needs it. A grant row of that role can only reach a real account, and that
account then reads every hash through any query it is already allowed to make:

```osy title="what never to write outside a test" syntax
new RoleGrant { Grantee = ada, Level = AppRole.Authenticator };   // ❌ Ada can now read every password hash
new RoleGrant { Grantee = ada, Level = AppRole.Admin };           // ✅ give her the role she actually needs
```

`osy lint` reports such a grant as a MUST (`security-auth-role-granted-to-a-person`) wherever it would reach the
running app: a function, an app setting that hands roles out (a sign-up role, an invitation), or a
[dev seed](https://osysharp.com/reference/testing/dev-seeds/) and any fixture it builds on. Inside a `[Test]`, or a `[TestFixture]` that is not a dev
seed, it is fine — granting a stand-in account the role is how a test runs something *as* the sign-in flow, and a
test's world is its own.

The operator commands refuse it too. `osy user add --role <that role>` (and `osyrin app user add`, and `osyrin app
user roles`) says why and lists your other roles, and writes nothing — see [[local-adding-an-account#sign-in-role]].
The role is the one your `app.AuthBootstrap` names — or a kit's, when the kit states the bootstrap for you — whatever
it is called; `Authenticator` is only the usual name.

### Can an `[AuthMethod]` run any other way? No — it runs only as the sign-in flow   {#only-the-sign-in-flow}
An `[AuthMethod]` runs **only** as that principal. The platform sets it up on the routes a sign-in arrives by — a page
calling the method (the hand-off), a REST, MCP or console call, its own sign-in routes, and a test's in-process call —
and every one of those runs is a [sign-in attempt](#sign-in-limits). On any other route the method is **refused**, with a
sentence naming the routes that run it, instead of running as its caller:

- `POST /api/context/{id}/fn/{function}` — the direct call by id;
- an ordinary function calling it in-process (`string Proxy(e, p) { return SignIn(e, p); }`), a workflow, `osy query`;
- an `[AuthMethod]` no slot wires, which can never be armed, on any route at all.

The rule is checked where every route converges — the interpreter, as it enters the method — so a route added later
inherits it. Before, the direct route admitted a wired sign-in and ran it as the anonymous caller: it read no account and
answered "wrong password" for a right one, and it was a second, unlimited way to reach the sign-in code.
SECURITY-INVARIANT(an-authmethod-runs-only-as-the-sign-in-flow).

### Granting the auth role — by ROLE, never by the caller's row   {#when-not-where}
This is the one rule that will catch you out, and the reason is worth understanding — it follows from the ephemeral
principal having **no user**.

A grant to the auth role must name a **role** policy — never a condition that goes looking for a row belonging to the
caller:

```osy title="grant the auth role by its role, not by the caller's row" syntax
allow read  when IsAuthenticator;    // ✅ a fact about the PRINCIPAL's ROLE — true for the ephemeral one
allow read  where User == user;      // ❌ compares a column to the caller's user id — and there ISN'T a user
```

A role policy is answered from **the role the principal bears** — no row is consulted, so it is true for a principal
that has no rows at all. Anything that has to *find a row for this user* — `User == user`, or a policy that hops into
a membership table by anything other than role — resolves the caller's user id, which for the ephemeral principal is
nothing. It matches no rows, and denies. That is correct (a caller who is nobody owns nothing), but it means the auth
role cannot be granted that way.

"Your own row, or staff, or the sign-in" is three kinds of caller, so it is three rules — each `when` says WHO, and
only the first asks anything about the row. A role test written into a `where` instead is a compile error that prints
this split ([when is who, where is which rows](https://osysharp.com/reference/security/when-and-where/)); a package's entity says it exactly the same way:

```osy title="own row, staff, or the sign-in — a rule per kind of caller" syntax
allow read when IsAnonymous || IsAuthenticated where Id == user.Id;
allow read when IsStaff;
allow read when IsAuthenticator;
```

So: **grant the auth role by role; grant everyone else however you like.** The row filters stay for real, signed-in
users, where they work perfectly.

**And it is every operation the flow performs, not just `read`.** `Login` reads. `Signup` also *creates* — the
principal row, and usually a role grant. `AcceptInvite` *updates* the invite it just consumed, and *reads* the
membership table to stay idempotent. Each of those needs its own grant to the auth role, and each fails its own way:
a missing `create` refuses the signup for everyone; a missing `update` refuses the write and takes the whole flow down
with it; a missing `read` answers "nothing found", so the check silently stops being made.

⚑ A column written on a row the flow **just created** is covered by `allow create` — it is not an `update` until the
row exists. That is why stamping a last-login timestamp needs `allow update LastLoginAt when IsAuthenticator;` for the
sign-IN path and nothing extra for the sign-UP path, from the same line of source. ⚠ That line only ADDS the column for
the auth flow: if a signed-in user may edit their own row, they can still stamp their own login too. To make the auth
flow the only writer, state the column's blanket beside it — `deny update LastLoginAt;` — see
[[security-entity-security#field-write]].

### How must the role policy be written?   {#policy-shape}
For `when IsAuthenticator` to be answerable from the role alone, the policy must be written in the **role-grant
shape**: an existence check over a grant entity, correlating the principal and comparing the role column to a member
of your `[Role]` enum. That shape is recognised and answered from the principal's roles — which is exactly why it is
true both for a real admin (who holds a grant row) and for the ephemeral authenticator (who holds only a minted role).

```osy title="the shape the engine recognises" test app=security-auth-bootstrap
[Role] enum AppRole { Authenticator, Member, Admin }

[Principal]
entity User {
  [Required, Unique, MaxLength(255)] string Email;
  [MaxLength(200)] string PasswordHash;
  [MaxLength(64)] string ResetToken = "";          // where a reset in flight is recorded — see StartReset below
  security {
    allow read   when IsAuthenticator;             // ← true for the ephemeral principal
    allow create when IsAuthenticator;
    allow update when IsAuthenticator;             // ← a reset writes the row it just looked up
    allow read where Id == user.Id;                // ← for real users, by row
    deny read PasswordHash when !IsAuthenticator;  // the hash is the auth flow's alone
    deny read ResetToken   when !IsAuthenticator;  // …and so is a live reset token
  }
}

[RoleGrant] entity RoleGrant {
  [Required] User User;                            // the principal reference…
  [Required] AppRole Role = AppRole.Member;        // …and the [Role] enum column: this is a role-grant entity
  security {
    allow read   when IsAdmin;
    allow create when IsAuthenticator;             // signup may mint one — see the role-grants page
  }
}

policy IsAuthenticator => RoleGrant.Any(g => g.User == user && g.Role == AppRole.Authenticator);
policy IsAdmin         => RoleGrant.Any(g => g.User == user && g.Role == AppRole.Admin);
```

The same policy serves both kinds of caller, which is the elegance of it: for a **real** user it means "you hold a
grant row saying Admin"; for the **ephemeral** principal it means "the role you were minted with is Authenticator".
You write one sentence, and it is true in both worlds.

**Other tiers need not be roles.** A policy that is *not* a role-grant — say `IsOrgAdmin`, an existence check over a
membership table whose enum is an ordinary domain enum — is a perfectly good `when` guard and a perfectly good
[[ui-authorize|`[Authorize]`]] policy. It is simply answered a different way: by looking for the row. See
[role grants (and the first admin)](https://osysharp.com/reference/security/role-grants/), which is where the two tiers are laid out side by side. The only caller such a policy can
never be true for is the ephemeral one — because it has no rows.

### If nothing tests the role, the role decides nothing   {#role-tests-nothing}
`Role` is the ephemeral principal's **only** property — it is minted with no user, so "I hold this role" is the whole
of what it can say about itself. A role makes no difference to anything until a guard tests for it, so an app that
declares `Role = AppRole.Authenticator` and then never mentions `Authenticator` in a `policy` body or in any
`security` rule's `when` has armed a principal that can prove nothing. Both grants the flow depends on become
unwritable at once:

- **the grant.** `when IsAuthenticated` is FALSE for this principal (it has no user) and a `where` row-filter has no
  user id to match, so with no role predicate available the only grants that admit the login also admit everyone.
- **the mask.** `deny read PasswordHash when !IsAuthenticator;` is how the hash is hidden from every reader *but* the
  login. With no policy to negate, the two things left are both wrong: an unconditional `deny read PasswordHash;`
  drops the column from the login's own `SELECT`, so every correct password is refused; no `deny read` at all ships
  the hash to the browser inside `Session.CurrentUser`.

None of that is a compile error — the app builds, and signing **up** may work perfectly, which is what hides it. The
maturity rule `security-auth-role-tests-nothing` (MUST) reports it, and its remediation carries the missing
declarations written in your app's own names:

```osy title="the role is armed and nothing tests it" syntax
[Role] enum AppRole { Authenticator, Member }

[Principal]
entity User {
  [Required, Unique, MaxLength(200)] string Email;
  [MaxLength(200)] string PasswordHash;
  security { allow read, create when IsAuthenticated; }   // ← never true for the auth flow
}
// …and no policy anywhere mentions AppRole.Authenticator

app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login, Signup = Signup };
```

The fix is the grant entity plus the policy that reads it — the [role-grant shape](https://osysharp.com/reference/security/role-grants/) above —
after which `when IsAuthenticator` is answerable from the role alone.

⚠ **The role may be tested inline**, without a named policy in between: `allow read when RoleGrant.Any(g => g.Grantee
== user && g.Level == AppRole.Authenticator);` is the same claim, the engine answers it the same way, and the rule
stays silent on it. What it reports is a role no guard in the app names at all.

### `LoginPage` — where a signed-out visitor gets sent   {#login-page}
`LoginPage` names your app's login `[Route]` component, so the platform knows **where to send** a signed-out visitor
who lands on a protected page — your declared route, rather than a guess. It carries no authority; it is a route.
Omit it and the client falls back to `/login`.

### There is no signup PAGE slot — that one is on you   {#no-signup-page}

`AuthBootstrap` names a `Signup` **function** and a `LoginPage` **component**. It never names a signup *page*, so
nothing in the language asks for one — and an app whose only way in is a login form nobody can get an account from is
locked, on a clean compile, with every test green.

⚠ **Measured 2026-08-18**, on an app built from the shape of this page: `Signup` and `Login` were written as
`[AuthMethod]`s, `LoginPage` was wired, and there was **no page anywhere that creates an account**. Write the
`[Route("/signup")]` yourself, and link to it from the login page.

### Prove the ROUND TRIP, not the signup   {#round-trip}

Signing up leaves you signed in, so a test that stops there passes while signing back in is still broken — the two
paths share almost nothing but the entity. Drive all three:

```osy syntax
Ui.Visit("/signup");
Ui.Fill("Email", "sam@example.com");
Ui.Fill("Password", "correct horse battery staple");
Ui.Click("Create account");
Assert.OnPage("/");

Ui.SignOut();
Ui.Visit("/login");
Ui.Fill("Email", "sam@example.com");
Ui.Fill("Password", "correct horse battery staple");
Ui.Click("Sign in");
Assert.OnPage("/");        // ⛔ the assertion the signup-only test never makes
```

⚠ **Measured the same day:** signing up got the person in, signing back in did not, and the app's own two auth tests
were `[Skip]`ped — so nothing said so. A skipped auth test is worse than none: it reads as coverage.

### `OAuthSignup` / `OAuthLink` — someone signs in with a provider and has no local account yet   {#oauth-slots}
The two `OAuth*` slots wire the pages that finish an **OAuth sign-in** — the case where a visitor signs in with a
provider (Google, …) but has no account yet, or a local account that is not yet linked. Each names an `[AuthMethod]`,
armed with the same ephemeral role as `Login`/`Signup` (so what it may write is exactly your `security { }` grants for
that role, nothing more):

- **`OAuthSignup`** — the function your **new-account** completion page calls: it reads the provider-verified email out
  of the sealed pending token, creates the account, records the identity link, and returns the ticket.
- **`OAuthLink`** — the function your **link-confirm** page calls, when the email already belongs to a local account:
  it attaches the provider identity to that account so next time the provider signs them straight in.

The mechanics — how the email is sealed into the token so the browser cannot forge it, and the two server calls
`Security.VerifyPendingOAuthEmail` / `Security.LinkOAuthFromPending` — live on their own page: see
[OAuth account completion — Security.VerifyPendingOAuthEmail / LinkOAuthFromPending](https://osysharp.com/reference/security/oauth-completion/). Both slots are optional; omit them and the app simply offers no OAuth sign-in.

### `OAuthSignIn` and `OAuthUnlink` — the app decides every provider sign-in   {#oauth-sign-in}
Without `OAuthSignIn`, the platform's OAuth callback signs an identity that is already linked straight in — a ticket in
the redirect, before any of your code runs — and hands the other two cases to the pages above. That is fine for an app
whose sign-in is only a provider; it is wrong for one with a **second factor**, an **inactive** account, or a sign-up
policy, because none of them is asked.

**Wire `OAuthSignIn`, and the callback resolves no account and issues no ticket at all.** It verifies who the provider
says this is, seals it, and redirects EVERY provider sign-in to your `/oauth/sign-in` page with the sealed token in the
fragment; your `[AuthMethod]` decides — which account it is (`Security.PendingOAuthLinkedUser(token)` answers the account
an identity is already linked to, without your code ever seeing the provider's id for the person), whether a stranger
may sign up, and whether a code comes next. The id-token exchange (`POST /api/app-auth/oauth`) refuses for such an app,
since it would be a way round all three. SECURITY-INVARIANT(provider-sign-in-is-the-apps-when-it-wires-it).

`OAuthUnlink` removes a provider from an account (`Security.UnlinkOAuth(userId, provider)`) — armed because the link is
the platform's own row, which no signed-in person writes. `Security.OAuthLinkedProviders(userId)` lists an account's
providers and `Security.SignInProviders()` the ones the app offers — declared, with a client id and a value for the
client's secret on this installation. [[security-accounts-kit#providers|The UserAccounts kit]]
wires both slots, with `OAuthLink` for linking from the account page.

### `AcceptInvite` — an already-invited teammate turns their token into an account   {#accept-invite}
`AcceptInvite` wires the flow where an **already-invited** person joins — a teammate you emailed an invite link, not a
stranger signing themselves up. The invitee arrives **anonymous**, carrying an invite token, and your accept function
turns that into an account and a membership. It is the same shape as the OAuth slots — a foreign principal presenting a
credential — so it is armed with the same ephemeral role as `Login`/`Signup`, and what it may write is exactly your
`security { }` grants for that role, nothing more:

- **`AcceptInvite`** — the `[AuthMethod]` your **invite-accept** page calls. It takes the invite token plus whatever the
  new user supplies (a password, a display name), and its body does the leashing: it **validates the token** (that it
  exists, is unspent, and is not expired), provisions or authenticates the user, records their org membership, and
  returns the ticket from [`Security.IssueJwt`](https://osysharp.com/reference/stdlib/security/). An invalid or spent token returns `""` — no ticket,
  no account.

The token check is the whole security boundary — the ephemeral role lets the function *reach* the invite and membership
rows, and the function's own validation is what decides whether this caller may have them. It is optional; omit it and
the app simply offers no invite-acceptance flow.

### `PasswordChange` and `EmailChange` — a signed-in account changes its own   {#signed-in-changes}
A signed-in person changing their password or address is the one flow where being signed in is not enough: the
session cannot read the stored hash (nobody but the sign-in can), and it should not be able to — a session left open
on a shared computer must not become a new password. So the change is a wired method like the others, and it takes
the **current** password as its proof:

```osy syntax
[AuthMethod]
string ChangePassword(string email, string currentPassword, string newPassword) {
  var m = Member.Where(x => x.Email == email).FirstOrDefault();
  if (m == null || !Security.VerifyPassword(currentPassword, m.PasswordHash)) { throw new Exception("That is not your current password."); }
  Security.EndOtherSessions(m.Id);   // every other browser signed in to this account is signed out
  m.PasswordHash = Security.HashPassword(newPassword);
  UnitOfWork.Commit();
  return Security.IssueJwt(m.Id, m.Email);
}

app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login,
  PasswordChange = ChangePassword, EmailChange = ChangeEmail };
```

A password change (and a `PasswordReset`) should end the account's other sessions —
[[stdlib-security#end-other-sessions|`Security.EndOtherSessions`]] — so whoever was signed in with the old password is
not still signed in after it; the session making the change stays.

`EmailChange` is the same shape with a new address instead of a new password; refuse an address another account
already uses, and tell the OLD address it happened — that is how the owner finds out if it was not them. Both return
a fresh ticket, because the old one names the old address.

### `EmailVerifyRequest` and `EmailVerify` — proving an address receives mail   {#email-verify}
Recording that an address is **proved** means recording that a message sent to it was received. So the row that
remembers an outstanding verification must hold only a **hash** of the mailed token — and, just as important, **only the
sign-in flow may write that row**. Whoever writes the row chooses which hash opens it: a signed-in account allowed to
create its own verification row can pick a token, store its hash, and "verify" an address it never received mail at.

That is why both halves are slots. `EmailVerifyRequest` mints the token *inside* the armed method, stores its hash and
mails the token; `EmailVerify` takes the token from the link and records the proof. Grant the verification entity to the
auth role alone, and close the verified-at column (and the address itself) to everybody else:

```osy syntax
entity EmailCheck {
  [Required] User Account;
  [Required, MaxLength(255)] string Email;
  [Required, MaxLength(80)] string TokenHash;      // the hash — the token is in the mail and nowhere else
  [Required] DateTime ExpiresAt;
  DateTime? UsedAt;
  security { allow read, create, update when IsAuthenticator; }   // nobody signed in writes one
}

[AuthMethod]
void RequestVerification(string email) {            // answers nothing, hit or miss — not an account oracle
  var u = User.Where(x => x.Email == email).FirstOrDefault();
  if (u == null) { return; }
  var token = Security.RandomId(40);
  new EmailCheck { Account = u, Email = u.Email, TokenHash = Crypto.Sha256Hex(token), ExpiresAt = DateTime.UtcNow.AddDays(3) };
  // … mail App.BaseUrl + "/verify/" + token to u.Email
}

[AuthMethod]
string Verify(string token) {                        // the /verify/{token} page calls this, signed in or not
  var c = EmailCheck.Where(x => x.TokenHash == Crypto.Sha256Hex(token)).FirstOrDefault();
  if (c == null || c.UsedAt != null || c.ExpiresAt < DateTime.UtcNow || c.Account.Email != c.Email) { return ""; }
  c.UsedAt = DateTime.UtcNow;
  c.Account.EmailVerifiedAt = DateTime.UtcNow;
  return c.Account.Email;
}

app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login,
  EmailVerifyRequest = RequestVerification, EmailVerify = Verify };
// and on User:  deny update EmailVerifiedAt;  allow update EmailVerifiedAt when IsAuthenticator;
//               deny update Email;            allow update Email when IsAuthenticator;
```

⚠ The request is armed, so **anybody** may call it — which is fine, because it only ever mails the address already on
the account and says the same thing whether or not one has it. A request running as the *caller* is the shape to avoid:
it needs a create grant on the entity, and that grant is the hole.

**`EmailVerifyCode` — the code beside the link.** A verification mail may carry a six-digit code as well as the link, for
the person still on the page; this slot takes the address and the code. It is a slot for the same reason: the code's
record (a keyed fingerprint, never the code) must be written by the flow alone, and proving it writes the verified-at
column. [[security-accounts-kit#codes|The UserAccounts kit]] wires it, with the bounds a code needs.

### The phone slots — `PhoneVerify`, `PhoneSignIn`, `SmsSecondStep`   {#phone-slots}
Slots for the same reason as the rest: a proven number is a column nobody signed in may write — it can sign an account
in, and be its second step — so proving one (`PhoneVerify`), signing in with a code texted to one (`PhoneSignIn`) and
choosing a texted code as the second step (`SmsSecondStep`) all run as the sign-in flow. [[security-accounts-kit#phones|The
UserAccounts kit]] wires them, each off until the app turns it on. ⛔ A texted code is never a second factor an app can
REQUIRE: the kit asks such an account for an authenticator app.

### `EmailDeliveryProblem` — did the mail arrive?   {#email-delivery-problem}
A step that waits for a mailed code should say so when the code cannot arrive: the address has no mailbox, or the
carrier holds mail to it back after an earlier bounce. What the carrier reported is kept on the app's outbox, which only
the sign-in flow may read (it holds reset links and codes), so the question is a slot. It answers about an ADDRESS, never
about an account — a sign-up mails any address it is given. [The UserAccounts kit](https://osysharp.com/reference/security/accounts-kit/) wires it to
`AddressMailProblems`, and its address step asks it while it waits.

### How does the old address say "this wasn't me"? — `EmailChangeCancel`   {#email-change-cancel}
When an account asks to move to a new address, the OLD address is told — and told by somebody who may not be its
owner: whoever asked knows the password. So the mail carries a single-use link that calls the move off, and the slot is
what opens it from a signed-out browser. It takes the link's token alone (no password: the asker has it), spends every
confirmation still on its way to the new address, and ends the account's sessions
([[stdlib-security#end-other-sessions|`Security.EndOtherSessions`]]). It is a slot because those confirmations are rows
no signed-in person may write. [The UserAccounts kit](https://osysharp.com/reference/security/accounts-kit/) wires it to `CancelAddressChange`, served
at `/cancel-address-change/{token}`.

### How a program signs in: `MachineSignIn`   {#machine-sign-in}
A program that calls the app — an ops agent, a nightly job — has no password and no second factor to type, and should not
hold a key that works forever. This slot is its door: the method takes ONE argument, a refresh token the program holds, and
answers an object with a `Ticket` and a `RefreshToken` — the ticket the same one a person's sign-in gets
(`Security.IssueJwt`), the refresh token the one that replaces the token just spent. A program reaches it with no browser:

```http title="a program's sign-in" syntax
POST /api/app-auth/console/machine-token        (anonymous; the app by its subdomain or X-App-Slug)
{ "refreshToken": "…" }
→ 200 { "ticket": "…", "refreshToken": "…" }    → 401 { "error": "<the method's Refusal>" }
```

Which principal a token names, whether it is still good and when it is spent are the METHOD's — armed like every slot, it
reads token rows nobody else may. ⚑ A refusal is a RESULT (an empty `Ticket` and a `Refusal`), not a throw: what a method
decides when it refuses — that a token presented twice revokes its principal — must commit, and a throw rolls it back.
Admin's machine principals are built on it (a token is good once; a second use revokes the principal).

### How a CI job signs in, holding no secret: `CiSignIn`   {#ci-sign-in}
A CI job has something better than a refresh token: its CI signs an OpenID Connect token for that one run (GitHub
Actions with `permissions: id-token: write`), naming the repository, the branch and the workflow that ran. This slot is
its door, and it is reached through an ordinary [[api-push-endpoints#oidc|endpoint that declares `OidcIssuer`]], so the
PLATFORM verifies the token — signature against the issuer's published keys, issuer, lifetime — and the method reads only
what that established, with `Request.OidcClaim(audience, name)`:

```osy title="a CI job's sign-in, wired and published" syntax
[AuthMethod]
Ticket CiSignIn() {
  var repository = Request.OidcClaim("my-app-ci-sign-in", "repository");   // null unless a verified token for this audience
  var workflow   = Request.OidcClaim("my-app-ci-sign-in", "job_workflow_ref");
  // …find the program federated with exactly this repository, workflow and branch, and issue its ticket
}
app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, CiSignIn = CiSignIn };
app.Apis = [ new RestApi("Machines") { Route = "machines", Auth = new ApiAuth { Anonymous = true },
  Endpoints = [ new Endpoint(CiSignIn) { Method = HttpMethod.Post, Path = "/ci-sign-in",
                                         OidcIssuer = "https://token.actions.githubusercontent.com" } ] } ];
```

Armed like every slot, because it reads the rows that say which run is which program. ⚑ Called any other way — the
anonymous auth-method door, another function, a test — it has no verified token to read and signs nobody in; a `.osy`
test drives it with `TestOidc.Bearer(issuer, audience)` and `TestOidc.Claim(name, value)`, which stand in for that
verification and are refused outside test code. Admin's app deployer is built on it (D-APPDEPLOYCI1).

### Which slots does a passkey need?   {#passkeys}
A passkey sign-in is a ceremony in two halves, and both need the ephemeral principal: `PasskeyChallenge` mints a
challenge the browser's authenticator signs (kept by the sign-in flow alone, so nobody can make one whose challenge
they chose), and `PasskeySignIn` checks the answer and issues the ticket — a passkey with user verification is both
steps at once, so no code follows it. `PasskeyAdd` and `PasskeyRemove` change which passkeys an account has, and
`PasswordRemove` turns password sign-in off for an account that has one; each writes rows no signed-in person may, which
is what makes them slots. [The UserAccounts kit's passkeys](https://osysharp.com/reference/security/passkeys/) wire all five; the checks on the
browser's answer are [`WebAuthn.*`](https://osysharp.com/reference/function/webauthn/).

### The `SecondFactor*` slots — two-step sign-in   {#second-factor}
A second factor splits sign-in in two, and both halves need the ephemeral principal: `Login` verifies the password and,
for an account with a second factor, answers a short-lived **challenge instead of a ticket**; `SecondFactorSignIn` takes
that challenge and a code and issues the ticket. The other three change the factor — turning it on, turning it off,
replacing the recovery codes — and are slots for the reason `EmailVerifyRequest` is one: the factor's secret, and every
field that says whether one guards the account, must be writable by the sign-in flow **alone**. A borrowed session that
could write them could turn the factor off, or on with a phone of its own.

These slots are what [the UserAccounts kit](https://osysharp.com/reference/security/accounts-kit/) wires for its two-step sign-in, and the kit is the way to
get it — the slots are here so any sign-in flow can be armed the same way. Pair them with
[`SecondFactorField`](https://osysharp.com/reference/security/password-auth/) on `app.Auth`, so the platform's own password check refuses an account
whose factor is on.

**The platform's password paths defer to it.** Once `SecondFactorSignIn` is wired, the console sign-in
(`POST /api/app-auth/console/login` — `osyrin login`, `osyrin app login`, every CLI sign-in), the local sign-in
(`/api/app-auth/login`) and `--as` run your `Login` rather than checking the password themselves, and accept only what it
answers. The console answers a challenge as `{ challenge }`, and the CLI asks for the code (or takes `--code`) and sends
it to `POST /api/app-auth/console/login/code`, which runs `SecondFactorSignIn`; an account that must set a factor up gets
a 403 telling it to do so in a browser. The local sign-in and `--as` cannot ask for a code, so for them only a ticket
admits. SECURITY-INVARIANT(password-paths-defer-to-the-apps-two-step-sign-in) — without it, an account your app REQUIRES
a factor of and that has not set one up would get a ticket from the password alone.

⚑ **The operator key stands in for the code at the console.** A console sign-in carrying a valid `X-Operator-Key`
takes the platform's own password check — the key is the platform's root of trust (it alone mints a platform admin), so
it proves more than a code does. That is how the local toolchain (`osy compile`/`test`/`launch`, which read the key from
the instance's own secrets) signs its implicit admin in to an Admin whose operators must use two-step sign-in. An
account with a factor ON is still refused on the password alone. SECURITY-INVARIANT(the-operator-key-stands-in-for-a-second-factor).

### What does `Login` return, and what does the page do with it?   {#ticket}
Your `Login`/`Signup` returns the session ticket from [`Security.IssueJwt(userId, email)`](https://osysharp.com/reference/stdlib/security/). The login
page hands it to `Session.SignIn(ticket)`, which stores it as the session bearer — so the *next* request is
authenticated as that user, with their real grants, and the ephemeral principal is gone. A failed sign-in returns
`""`, `Session.SignIn` stores nothing, and the visitor stays anonymous.

### How long does a sign-in last?   {#session-length}
As long as the person keeps using the app. Every request they make keeps their session going; once they stop, it ends
**an hour after their last request**, and their next visit goes to the login page. An idle tab holding a live connection
does not count as using the app, so it does not keep the session going.

The ticket is good for an hour from when it was issued, and the platform **renews it while they work**: once a ticket
is past half its life, the answer to an ordinary request carries a fresh one, and the client uses it from then on — for
its requests, its live connection, and the next page load. You write nothing for this. A renewed ticket is the same
sign-in — the same person and the same session, with nothing added — so `Session.SignOut()`, a password change that
calls `Security.EndOtherSessions`, or an administrator signing someone out ends it exactly as it ends the original, and
a session that has already ended is never renewed.

A client of your own (not the platform's browser client) that wants the same behaviour reads the renewed ticket from
the `X-Osy-Token` response header and sends it from then on; one that ignores it keeps working until its ticket's hour
is up.

### How many wrong passwords? The sign-in limits   {#sign-in-limits}
The platform limits password guessing on every sign-in path — your wired `[AuthMethod]`s, whichever way they are
called, and its own `/api/app-auth/login` and console sign-in — and you write nothing for it. It counts **failures, not
requests**: a right password costs nothing, so an office full of people signing in from one address all morning is
never refused.

- **Per client address:** ten failed sign-ins a minute to one app, then every attempt from that address is answered
  `429` with *"Too many sign-in attempts. Wait N seconds and try again."* (and a `Retry-After`) until the minute is up.
- **Per account:** five failures, then each further attempt must wait — one second, doubling to five minutes — from
  whichever address it comes, so guessing spread over many machines is slow too. It is **never a lock**: once the wait
  has passed the right password works, and an address the account has signed in from before is not delayed at all, so
  nobody can keep a colleague out by failing their password on purpose.

How the platform knows without being told: **`Security.VerifyPassword` is the signal.** A password that verifies is a
success — even when your `Login` then answers a two-step challenge rather than a ticket — and one that does not is a
failure, including the one-argument form you call when no account matched. A sign-in call that throws with no password
verified (a refused sign-up) counts as a failure too.

Which account: the one the caller **named** — the parameters the account row is **looked up by**, as typed, trimmed and
lower-cased. The compiler reads that off your body: in `VerifyPassword(password, account.PasswordHash)`, the row
`account` came from a query, and the parameters in that query's predicate are the account — through locals, and through
a helper that does the checking (`Verified(email, password)`). So a free argument before the password — a captcha token,
a remember-me flag — never splits one account into as many as there are guesses. Only where the compile cannot tell (a
hash handed in as a parameter) does it fall back to the arguments before the password. Not the stored hash, which only a
real account has: a limit keyed on it would tell a stranger which addresses have accounts. An address with no account is
delayed exactly like one with an account, and the refusal is the same sentence.

```osy syntax
[AuthMethod] string Login(string email, string password) { … }                   // limited per `email`
[AuthMethod] string Login(string workspace, string email, string password) { … }   // per (`workspace`, `email`), when both find the row
[AuthMethod] string Login(string email, string captcha, string password) { … }     // per `email` — the captcha finds no row
```

⚠ Your `[AuthMethod]` must call `Security.VerifyPassword` — both forms — on every refusal, which it already must so a
miss on an unknown address takes as long as a wrong password. A sign-in that compares a credential some other way is
counted only when it throws.

A refusal the app catches is still the answer: the attempt answers `429`, and nothing it wrote is kept. The limits are
held per server and shared between servers as they happen; a local dev platform (a loopback connection) is not limited.

### How many new accounts? The sign-up cap   {#sign-up-cap}
Sign-up is capped per client address too: **30 new accounts an hour** to one app, then `429` with *"Too many new accounts
have been made from this network. Wait N minutes and try again."* It counts sign-ups that **went through** — an account
made, or the answer a taken address gets (which mails its owner) — because it bounds account creation and the mail it
sends, not guessing; a refused sign-up (a short password, sign-up closed) spends nothing. It holds for your `Signup` slot
on every route and for the platform's `/api/app-auth/register` and console sign-up, is shared between servers like the
sign-in limits, and exempts the same loopback connections. Thirty is deliberately generous: a shared office or a
carrier's network puts many real people behind one address. SECURITY-INVARIANT(sign-ups-are-capped-per-address).

### Signing out   {#sign-out}
`Session.SignOut()` is the mirror of `Session.SignIn` — it **ends the session ticket and returns to the login page**.
The ticket is ended on the server too, not only dropped from the browser: every ticket has a session there, and the
sign-out ends it on every server at once, so a copy of the ticket (another tab, anything that read it) stops working
with it. It takes no arguments and, like `Session.SignIn`, runs on the client, so it belongs in an `action` body wired
to a control's `onClick`:

```osy syntax
action SignOut() { Session.SignOut(); }
// …
Pressable(onClick: SignOut) { Text("Sign out"); }
```

### Who is signed in? `Session.CurrentUser`   {#current-user}
`Session.CurrentUser` reads the **current authenticated principal** as your app's `[Principal]` entity — `var me =
Session.CurrentUser;` then `me?.DisplayName`, `me?.Email` shows who is signed in (a shell footer, a "my account" page).
It is RLS-scoped (the caller reads their own row), returns **null for an anonymous session** (or while the row loads),
and requires the app to declare a `[Principal]` entity (else it is a compile error). Under the hood it is an ordinary
data read — `<Principal>.SingleOrDefault(p => p.Id == <the current principal>)` — no round-trip you wouldn't already pay
for a query, and it reflects edits to the row like any other read.

```osy syntax
[Layout] component Shell() {
  var me = Session.CurrentUser;
  render { Text(me?.DisplayName ?? me?.Email ?? "Signed out"); }
}
```

### A package may ship the identity   {#package-identity}
The `[Role]` enum, the `[Principal]` entity and the `[AuthMethod]` login do not have to be the app's own. A package
the app `use`s may declare all three — a shop kit owning its staff sign-in, say — and they count exactly as if the app
had declared them: `Role` is checked against the package's enum, `Login` names the package's function by its bare name,
and `user` in the app's own `security { }` rules is the package's principal.

```osy title="the app wires an identity its package ships" syntax
using Acme.Desk;   // declares [Role] enum DeskRole, [Principal] entity Clerk, [AuthMethod] ClerkLogin

app.AuthBootstrap = new AuthBootstrap { Role = DeskRole.Authenticator, Login = ClerkLogin };
app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash };   // the principal's bare fields

entity Memo {
  [Required("A memo needs an author.")] Clerk Author;
  security { allow read, create, update, delete where Author == user; }   // user IS the package's Clerk
}
```

- **Still one of each.** An app has one `[Role]` enum and one `[Principal]`, counted across its own source *and* its
  packages. A second — the app's beside a package's, or two packages' — is a compile error that names both declarers.
- **A package MAY wire it for the app.** A package that states `app.AuthBootstrap` (and `app.Auth`) states them for
  every app that uses it — naming the app's role as `role.Authenticator` ([`role` — a package's name for the app's own roles](https://osysharp.com/reference/security/role-type/)) — and an app that
  states none gets the package's. The app's own `app.AuthBootstrap` replaces the package's **whole** (nothing is
  merged); two packages that both state one are refused, and the app chooses by stating its own. An app function
  that shares its name with a function the package wires is refused by name, so a bare `Login` can never quietly mean
  two things. This is how [The UserAccounts kit — sign-in done properly, once](https://osysharp.com/reference/security/accounts-kit/) wires every flow: the app writes no auth wiring at all.
- **Its rules are its own.** The package's `security { }` on its principal and grant table is lowered in the app's
  compile, against that principal, and under the default `[Authority(Narrowable)]` an app may only tighten it — so give
  each operation to the auth role with `when` rules alone ([[Authority]](https://osysharp.com/reference/security/contribution-authority/)).

## Examples       {#examples}
The complete flow — the two auth methods, the page, and the wiring:

```osy title="login, signup, reset, and the page they sign you in from" test app=security-auth-bootstrap
// The delivery seam a reset needs. The platform has no `Email.Send`: an outbound provider is a class YOU write over
// `Http.*`, holding its url and the secret that authenticates it. See [a typed HTTP client (a class over Http.*)](https://osysharp.com/reference/http/client/).
using Osysharp.Http;

// `use` is a MANIFEST declaration — it belongs in your app.osy, not in a model file.
app Accounts {
  model "model/**/*.osy";
  use Osysharp.Http;
  use Osysharp.Ui;     // the login PAGE below is a kit control, not a bare atom
}

app.Secrets = [ new Secret("MailerKey") ];

class MailInput { public string To; public string Subject; public string? Text; }
class MailResult { [ExternalName("id")] public string? MessageId; }

public class Mailer {
  // ⭐ THE CREDENTIAL IS A FIELD, so the app decides whose it is — and a reset mail is sent on the APP's authority,
  //    never the caller's: the person asking for a reset is by definition not signed in.
  public string ApiKey  = "";
  public string BaseUrl = "https://api.mail.example";

  public MailResult Send(MailInput input) {
    var headers = new Dictionary<string, string>();
    headers.Add("Authorization", "Bearer " + ApiKey);
    var r = Http.Post(BaseUrl + "/send", JsonSerializer.Serialize(input), "application/json", headers);
    if (!r.IsSuccess) { throw new ExternalServiceException("mail send failed: " + r.StatusCode + " " + r.Body); }
    return JsonSerializer.Deserialize<MailResult>(r.Body);
  }
}

// A factory may return the configured client: the key is tracked into `ApiKey` and cannot be read back out
// ([[http-client#credential]]).

[AuthMethod]
string Login(string email, string password) {
  var u = User.Where(x => x.Email == email).FirstOrDefault();
  // Spend the same time either way — an early return makes "no such account" measurably faster
  // than "wrong password", and that difference is an enumeration oracle anyone can time. The
  // one-argument form verifies against nothing, costs a full KDF, and answers false.
  if (u == null) { Security.VerifyPassword(password); return ""; }
  if (Security.VerifyPassword(password, u.PasswordHash)) { return Security.IssueJwt(u.Id, u.Email); }
  return "";
}

[AuthMethod]
string Signup(string email, string password) {
  var u = new User { Email = email, PasswordHash = Security.HashPassword(password) };
  var grant = new RoleGrant { User = u, Role = AppRole.Member };
  return Security.IssueJwt(u.Id, u.Email);
}

// A reset has to MINT, STORE and SEND, and it is the third that gets left out. The token's whole job is to reach a
// mailbox only the account's owner reads; a flow that mints one and stops leaves the confirm step asking for a
// value nobody can obtain. `osy lint` reports that as `security-reset-token-never-delivered`, at MUST tier.
[AuthMethod]
string StartReset(string email) {
  var u = User.Where(x => x.Email == email).FirstOrDefault();
  if (u == null) { return ""; }        // say nothing either way — an unknown email is not news for a stranger
  var minted = Security.RandomId(32);
  u.ResetToken = minted;
  // Caught, so the mint survives a failed send: the call throws and a function commits at the END, so an uncaught
  // failure rolls the token back too — no mail, no token, and a page that looked like it worked.
  try { new Mailer { ApiKey = Secret.MailerKey }.Send(new MailInput { To = email, Subject = "Reset your password", Text = "Your code: " + minted }); }
  catch (Exception e) { Log.Error(e, "reset for {Email} was minted but could not be sent", email); }
  return "";                           // ⛔ NEVER the token. Handing it back lets anyone who knows your address take
}                                      //    the account — which is what the out-of-band channel exists to prevent.

[Route("/login")]
[AllowAnonymous]
[Render(CSR)]
component LoginPage() {
  string email = "";
  string password = "";
  action SignIn() { Session.SignIn(Login(email, password)); }
  action Register() { Session.SignIn(Signup(email, password)); }
  render {
    Input(value: email, placeholder: "Email");
    Input(value: password, placeholder: "Password", type: "password");
    Button("Sign in", onPress: SignIn);
    Button("Create account", onPress: Register);
  }
}
```

Read that against the grants above and the whole system is visible at once: `Login` can read `User` — and the hash,
which the field mask denies to everyone else — because `Authenticator` was granted a read. It cannot read anything
else, because nothing else granted it. Delete the `allow read when IsAuthenticator` line and login stops working;
nothing else in the app changes. That is what it means for the bootstrap to be leashed by ordinary rules.

## See also       {#see-also}
- [[AuthMethod] — a function an unauthenticated visitor may call](https://osysharp.com/reference/security/auth-method/) — the `[AuthMethod]` marker each wired function must carry, and the leash in detail
- [OAuth account completion — Security.VerifyPendingOAuthEmail / LinkOAuthFromPending](https://osysharp.com/reference/security/oauth-completion/) — the `OAuthSignup`/`OAuthLink` slots in full: sealed pending tokens and the two server calls
- [role grants (and the first admin)](https://osysharp.com/reference/security/role-grants/) — how the first account becomes an admin without opening a self-elevation path
- [Security.* — hashing, verifying, tickets, random ids](https://osysharp.com/reference/stdlib/security/) — `HashPassword` · `VerifyPassword` · `IssueJwt` · `RandomId`
- [app.Auth — how the platform authenticates a user of your app](https://osysharp.com/reference/security/password-auth/) — `app.Auth`, the code-free way the platform can authenticate a user of your app
- [secure by default (deny-all)](https://osysharp.com/reference/security/secure-by-default/) — the deny-all posture the bootstrap unblocks
- [security { }](https://osysharp.com/reference/security/entity-security/) — the `security { }` grants that leash the ephemeral principal
- [page authorization (policies)](https://osysharp.com/reference/ui/authorize/) — page-level authorization, the request-time complement
