Summary#
Security.* is what an authentication function is built from — hash a password on the way in, verify it on the way
back, and hand out a ticket:
[AuthMethod]
string Login(string email, string password) {
var u = User.Where(x => x.Email == email).FirstOrDefault();
// No such account. Verify against nothing anyway — it costs the same as a real check, so the clock does not
// tell a stranger which addresses are registered. See "the no-account path" below.
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) };
return Security.IssueJwt(u.Id, u.Email);
}Signature#
string Security.HashPassword(string plain) // salted one-way hash — store THIS, never the password
bool Security.VerifyPassword(string plain, string hash) // does the plaintext match the stored hash?
bool Security.VerifyPassword(string plain) // no account to check — spend the time, answer false
string Security.IssueJwt(Guid userId, string email) // the session ticket a login/signup returns
string Security.RandomId() // an unguessable id, default length
string Security.RandomId(int length) // …of a given length
string Security.RandomHex(int length) // random hex charactersDescription#
How do I hash a password, and check one?#
HashPassword produces a salted, one-way hash. The salt is generated for you and travels inside the returned
value, so two users with the same password get different hashes — and the same password hashed twice is never the
same value. That has a consequence worth stating, because it surprises people: you cannot compare hashes.
if (u.PasswordHash == Security.HashPassword(password)) { … } // ❌ never true. Not "insecure" — WRONG.
if (Security.VerifyPassword(password, u.PasswordHash)) { … } // ✅ the only way to check a passwordA hash is an ordinary string, and the column you store it in is an ordinary bounded one:
[MaxLength(200)] string PasswordHash; // the column a hash goes in⚠ Nothing about the TYPE stops you comparing hashes — both sides of the ❌ line above are strings, so it compiles
and is simply always false. What catches it is osy lint, which reports it as security-password-compared-directly
at MUST tier. The guarantee that the hash never leaves is carried entirely by the field mask below, not by the type.
VerifyPassword takes the plaintext first, the stored hash second — the order matters, and swapping them fails
every login. It re-derives the hash with the salt it finds in the stored value and compares them safely.
The no-account path — VerifyPassword(plain)
A login that returns the moment no row matches is giving a correct answer with the wrong timing. Hashing is deliberately slow — that is what a password KDF is for — so an unknown address answers in microseconds where a known one takes the full work factor. The response body is identical and the clock is not, so anyone can feed a list of addresses to a page that is meant to be public and learn which of them hold accounts. That is a disclosure on its own (who banks here, who uses this clinic) and it is the first half of every credential-stuffing run.
The one-argument form is the fix, and it is a security primitive rather than a convenience — it verifies against
nothing, pays the same KDF, and answers false:
var u = User.Where(x => x.Email == email).FirstOrDefault();
if (u == null) { return ""; } // ❌ answers in microseconds — an enumeration oracle
if (u == null) { Security.VerifyPassword(password); return ""; } // ✅ costs what a real check costsosy lint reports the ❌ shape as security-login-enumerates-users, at MUST tier.
⚑ VerifyPassword(password, "") does the same thing — an empty stored hash still costs a full verify, deliberately,
so that a row with no credential (an OAuth-only account, a truncated column) cannot answer faster than a wrong
password either. Prefer the one-argument form: it says "there is nothing to check" rather than leaving the reader to
work out what an empty second argument means.
Store only the hash. The plaintext password should exist nowhere in your app: not in a column, not in a log, not on a
second entity "for the reset flow". HashPassword at the point of signup is the whole story, and the
field mask (deny read PasswordHash when !IsAuthenticator) is how you make sure the hash
itself is never read by anything but the login.
How do I mint a session ticket? — IssueJwt#
Security.IssueJwt(userId, email) mints the session ticket — a signed token, scoped to your app and to that user.
Return it from your [AuthMethod] Login/Signup, and the login page hands it to Session.SignIn(ticket), which
stores it as the session bearer: the next request arrives authenticated as that user, carrying whatever roles they
have been granted.
Two rules follow from what the ticket is:
- Issue it only after you have verified the credential.
IssueJwtdoes not check anything — it signs whatever user you name. It is the conclusion of a login, never a step in one. - Return
""for a failed sign-in, not a ticket and not an exception.Session.SignIn("")stores nothing and the visitor stays anonymous. Returning the same empty answer whether the email is unknown or the password is wrong is also what stopsLoginfrom telling a stranger which addresses have accounts.
A ticket carries the grants the user has at the moment the next request is served — it is a claim of identity, not a frozen snapshot of permissions. Grant a role during signup (see role grants (and the first admin)) and it is in force immediately.
How do I make an unguessable token? — RandomId / RandomHex#
Cryptographically random strings, for the things that must be unguessable: a password-reset token, an invite code,
an API key, a one-time link. RandomId() takes a default length, RandomId(length) a chosen one, and
RandomHex(length) gives you hex characters.
[AuthMethod]
string StartReset(string email) {
var u = User.Where(x => x.Email == email).FirstOrDefault();
if (u == null) { return ""; } // an unknown email is not news a stranger gets to hear
return Security.RandomId(32); // mint it, mail it, record it with an expiry — your flow
}Do not reach for these for a database key: an entity's Id is already a unique identifier. Reach for them when the
value's job is to be secret.
Has this secret been given a value? — IsSecretSet#
A declared secret has no value until someone sets one (osy secret set <NAME>), and reading an unset secret fails
at the point of use — which is usually deep inside the call that needed it. Security.IsSecretSet("NAME") answers
whether it has one, so a feature that depends on a secret can say so plainly instead:
string Summarise(string body) {
if (!Security.IsSecretSet("OPENAI")) { return "Summaries are off — no OPENAI key is configured."; }
return Llm.Complete(body);
}It answers whether a value EXISTS, never what it is — there is no verb that reads a secret out into your code.
Examples#
The declarations the examples above are written against — the credential entity, the roles, and the wiring that makes
Login/Signup/StartReset reachable while signed out:
[Role] enum AppRole { Authenticator, Member }
[Principal]
entity User {
[Required, MaxLength(255)] string Email;
[MaxLength(200)] string PasswordHash; // the HASH — the password itself is stored nowhere
security {
allow read when IsAuthenticator;
allow create when IsAuthenticator;
allow read where Id == user.Id;
deny read PasswordHash when !IsAuthenticator; // and only the auth flow ever reads even the hash
}
}
entity RoleGrant {
[Required] User User;
[Required] AppRole Role = AppRole.Member;
}
policy IsAuthenticator => RoleGrant.Any(g => g.User == user && g.Role == AppRole.Authenticator);
app.AuthBootstrap = new AuthBootstrap {
Role = AppRole.Authenticator,
Login = Login,
Signup = Signup,
PasswordReset = StartReset,
};See also#
- [AuthMethod] — a function an unauthenticated visitor may call — the
[AuthMethod]marker these functions carry - auth bootstrap (login, before anyone is signed in) — the identity they run as, and what it is allowed to touch
- app.Auth — how the platform authenticates a user of your app —
app.Auth, where the platform does the hashing and ticket-issuing for you - role grants (and the first admin) — granting a role at signup without opening a path to self-elevation