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 a
caller with no ticket. It gets no special access: it may touch exactly what your security { } blocks grant that
role — see the leash.
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
PasswordReset = <function>, // optional — an [AuthMethod] function
LoginPage = <component>, // optional — a routed [Page] 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)
};All eight 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 with no ticket 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.
Three properties follow, and together they are what make the scary corner safe:
- Only a declared method is routed. Any other function invoked anonymously gets the ordinary anonymous context —
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). - Only when unauthenticated. A caller who is already signed in and invokes
Loginis not elevated; they run as themselves. So no ordinary user can use the sign-in path to borrow the auth role. - 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.
Granting the auth role — it must be a when, in the role-grant shape#
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 be a when guard naming a role policy — never a where filter, and never a
policy 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 when IsAuthenticator guard 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 — a where filter, or a
policy that hops into a membership table — 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.
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.
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, MaxLength(255)] string Email;
[MaxLength(200)] string PasswordHash;
security {
allow read when IsAuthenticator; // ← true for the ephemeral principal
allow create when IsAuthenticator;
allow read where Id == user.Id; // ← for real users, by row
deny read PasswordHash when !IsAuthenticator; // the hash is the auth flow's alone
}
}
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.
LoginPage#
LoginPage names your app's login [Page] 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
[Page("/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", "sam@example.com");
Ui.Fill("Password", "correct horse battery staple");
Ui.Click("Create account");
Assert.OnPage("/");
Ui.SignOut();
Ui.Visit("/login");
Ui.Fill("Email", "sam@example.com");
Ui.Fill("Password", "correct horse battery staple");
Ui.Click("Sign in");
Assert.OnPage("/"); // ⛔ the assertion the signup-only test never makes⚠ Measured the same day: signing up got the person in, signing back in did not, and the app's own two auth tests
were [Skip]ped — so nothing said so. A skipped auth test is worse than none: it reads as coverage.
OAuthSignup / OAuthLink#
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.
AcceptInvite#
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.
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.
Signing out#
Session.SignOut() is the mirror of Session.SignIn — it drops the stored session ticket and returns to the login
page. There is no server round trip: the ticket lives in the browser and the server keeps no session to invalidate
(every request re-verifies the bearer, and a dropped bearer is simply anonymous). It takes no arguments and, like
Session.SignIn, runs in-process 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"); }
}Examples#
The complete flow — the two auth methods, the page, and the wiring:
[AuthMethod]
string Login(string email, string password) {
var u = User.Where(x => x.Email == email).FirstOrDefault();
if (u == null) { 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);
}
[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
return Security.RandomId(32); // mint a reset token, mail it, record it — your flow, your rules
}
[Page("/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");
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