Osy#betaRun it on Osyrin: osyrin.com

Reference / Security

auth bootstrap (login, before anyone is signed in)

app.AuthBootstrap = new AuthBootstrap { Role = …, Login = …, Signup = …, PasswordResetRequest = …, PasswordReset = …, PasswordChange = …, EmailChange = …, EmailVerifyRequest = …, EmailVerify = …, LoginPage = …, OAuthSignup = …, OAuthLink = …, AcceptInvite = …, SecondFactorSignIn = …, SecondFactorEnable = …, SecondFactorDisable = …, SecondFactorRecoveryCodes = …, OAuthSignIn = …, OAuthUnlink = …, EmailVerifyCode = …, PhoneVerify = …, PhoneSignIn = …, SmsSecondStep = …, EmailDeliveryProblem = …, MachineSignIn = …, PasskeyChallenge = …, PasskeySignIn = …, PasskeyAdd = …, PasskeyRemove = …, PasswordRemove = …, EmailChangeCancel = …, CiSignIn = … };

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.

stable3 examples compiled by CIsecurityauthenticationauthorization

Summary#

With deny-all 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:

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.

⚠ 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.

Signature#

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 for what they do — and AcceptInvite wires an invite-acceptance flow (below).

Description#

Why can't Login read the User row?#

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#

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). 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#

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:

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 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#

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. 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#

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:

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); a package's entity says it exactly the same way:

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?#

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).

[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), 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 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:

[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 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#

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#

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#

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:

Ui.Visit("/signup");
Ui.Fill("Email", "[email protected]");
Ui.Fill("Password", "correct horse battery staple");
Ui.Click("Create account");
Assert.OnPage("/");

Ui.SignOut();
Ui.Visit("/login");
Ui.Fill("Email", "[email protected]");
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#

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. 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#

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#

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. 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#

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:

[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#

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:

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#

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?#

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 wires it to AddressMailProblems, and its address step asks it while it waits.

How does the old address say "this wasn't me"? — EmailChangeCancel#

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 wires it to CancelAddressChange, served at /cancel-address-change/{token}.

How a program signs in: MachineSignIn#

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:

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#

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):

[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?#

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 wire all five; the checks on the browser's answer are WebAuthn.*.

The SecondFactor* slots — two-step sign-in#

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 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 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?#

Your Login/Signup returns the session ticket from Security.IssueJwt(userId, email). 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?#

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#

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.

[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 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#

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:

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

Who is signed in? Session.CurrentUser#

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.

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

A package may ship the identity#

The [Role] enum, the [Principal] entity and the [AuthMethod] login do not have to be the app's own. A package the app uses 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.

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) — 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 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]).

Examples#

The complete flow — the two auth methods, the page, and the wiring:

// 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.*)](/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#

Related

[AuthMethod] — a function an unauthenticated visitor may call

`[AuthMethod]` marks a sign-in function — login, signup, password-reset — as reachable by a visitor who is not signed…

role grants (and the first admin)

A role is granted by an ordinary entity you mark `[RoleGrant]` — one that holds a reference to your `[Principal]` and a…

secure by default (deny-all)

Deny-all is the posture, and it is the only one: an entity that declares no `security { }` block is denied to every…

security { }

The rules that decide who may read and write an entity's rows. A where clause filters by the row (the owner sees their…

Security.* — hashing, verifying, tickets, random ids

The calls an authentication flow needs: `HashPassword` (salted, one-way), `VerifyPassword` (constant-work comparison…

OAuth account completion — Security.VerifyPendingOAuthEmail / LinkOAuthFromPending

The two server-side calls that finish an OAuth sign-in on your own pages. When someone signs in with a provider…