Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Security

auth bootstrap (login, before anyone is signed in)

app.AuthBootstrap = new AuthBootstrap { Role = …, Login = …, Signup = …, PasswordReset = …, LoginPage = …, OAuthSignup = …, OAuthLink = …, AcceptInvite = … };

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 — but only for a caller who is not already signed in. 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 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 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.

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 Login is 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 user

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

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#

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 — any entity that has both a reference to your `[Principal]` and a property…

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…