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
Userrow, 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 callsSignupfor 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 needsosy 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 userA 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 IsAuthenticatedis FALSE for this principal (it has no user) and awhererow-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 unconditionaldeny read PasswordHash;drops the column from the login's ownSELECT, so every correct password is refused; nodeny readat all ships the hash to the browser insideSession.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 fromSecurity.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
429with "Too many sign-in attempts. Wait N seconds and try again." (and aRetry-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(andapp.Auth) states them for every app that uses it — naming the app's role asrole.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 ownapp.AuthBootstrapreplaces 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 bareLogincan 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 withwhenrules 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#
- [AuthMethod] — a function an unauthenticated visitor may call — the
[AuthMethod]marker each wired function must carry, and the leash in detail - OAuth account completion — Security.VerifyPendingOAuthEmail / LinkOAuthFromPending — the
OAuthSignup/OAuthLinkslots in full: sealed pending tokens and the two server calls - role grants (and the first admin) — how the first account becomes an admin without opening a self-elevation path
- Security.* — hashing, verifying, tickets, random ids —
HashPassword·VerifyPassword·IssueJwt·RandomId - app.Auth — how the platform authenticates a user of your app —
app.Auth, the code-free way the platform can authenticate a user of your app - secure by default (deny-all) — the deny-all posture the bootstrap unblocks
- security { } — the
security { }grants that leash the ephemeral principal - page authorization (policies) — page-level authorization, the request-time complement