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
void Security.EndOtherSessions(Guid userId) // end the account's other tickets, when this commits
void Security.SignOutEverywhere(Guid userId) // an administrator ends somebody's tickets, when this commits
int Security.SignedInElsewhere(Guid userId) // how many live tickets the account holds besides this one
string Security.RandomId() // an unguessable id, default length
string Security.RandomId(int length) // …of a given length
string Security.RandomHex(int length) // random hex characters
string Security.NewApiKey() // a new API key (`pk_…`) — show it once, keep its hash
string Security.HashApiKey(string key) // what is stored for that key on its principal, in this appDescription#
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. The algorithm is Argon2id (memory-hard, at OWASP's parameters), and its parameters are stored in the hash
too, so a hash written by an older setting — or a bcrypt one — still verifies. 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.
When no account matches, still spend the hash time — 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 sign the account out everywhere else? — EndOtherSessions#
Security.EndOtherSessions(account.Id) ends every ticket the account already holds in this app, except the one the
calling request carries — on every browser and every server, on its next request. Call it where the account's way in
changes: a password change, a reset, or (as the [[security-accounts-kit#sessions|UserAccounts kit]] does) the first proof of
an address that claims an account from a stranger who signed up with it first. The account's sessions are then valid
from that moment.
[AuthMethod]
string ChangePassword(string email, string currentPassword, string newPassword) {
var u = User.Where(x => x.Email == email).FirstOrDefault();
if (u == null || !Security.VerifyPassword(currentPassword, u.PasswordHash)) {
throw new ValidationException("That is not your current password.");
}
Security.EndOtherSessions(u.Id);
u.PasswordHash = Security.HashPassword(newPassword);
return "";
}- The sessions are chosen at the call and ended at the commit. A ticket issued after the call — the one a reset hands back, a sign-in a second later — is not among them, so a reset can end everything and then sign the person in. And a change that is rolled back (a refusal later in the same function) signs nobody out.
- The person making the change stays signed in; nobody else holding the account does. A reset made from a signed-out browser has no session of its own, so it ends them all.
- The refusal says why. The ended ticket's next request answers 401 with "You were signed out because this account's password was changed or reset in another session. Sign in again.", and a live socket it holds closes.
- It ends sessions of this app only, and only the account you name.
How does an administrator sign somebody out? — SignOutEverywhere#
Security.SignOutEverywhere(person.Id) is the same ending as EndOtherSessions, made by somebody who looks after the
account rather than by its owner — deactivating it, resetting its second factor, or answering "I lost my laptop". Every
ticket the person holds ends on its next request, and the refusal says who did it: "You were signed out because
somebody who looks after this app's people changed this account's access. Sign in again." — not that a password
changed, which the person did not do. Security.SignedInElsewhere(person.Id) answers how many live tickets the account
holds besides the calling request's — what the call would end — so a page can show it before anybody presses anything.
Osysharp.UserManagement is built on both.
int OtherSessions(User u) { return Security.SignedInElsewhere(u.Id); }
void SignOutLostLaptop(User u) {
Security.SignOutEverywhere(u.Id);
}Like EndOtherSessions, the sessions end when the call's work commits, and a change rolled back signs nobody out.
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.
string IssueApiKey(User owner) {
var key = Security.RandomId(32);
new ApiKey { Owner = owner, Token = key };
// Handed back HERE, once, to the caller who asked for it. The row itself never gives it up again — see the
// `deny read Token` on `ApiKey` below. That is how the platform's own keys work too: `osy user apikey generate`
// prints the `pk_…` once and stores only what it needs to check it.
return key;
}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.
⚠ A secret whose recipient is NOT the caller — a reset token, an invite code, a one-time link — is only half done
when you have minted it. Its whole purpose is to travel out of band and come back, so the app has to be able to
send it: a client class over Http.*, a secret for the provider, and a call. Mint it and stop, and the
step that later asks for it can be satisfied by nobody, however green the tests are — osy lint reports that as
security-reset-token-never-delivered (MUST). [[security-auth-bootstrap#examples]] shows a password reset with its
delivery wired, and The UserAccounts kit — sign-in done properly, once is the same flow done once for every app — demo/auth-demo uses it end to end with tests.
⛔ And never hand such a token back to the caller who asked for it. This example returned
Security.RandomId(32) from an [AuthMethod] string StartReset(string email) until 2026-09-04 — so anyone who
knew your address could ask for your reset token and be given it, which is precisely the account takeover the
out-of-band channel exists to prevent. The value goes to the mailbox; the caller learns nothing either way.
How do I make an API key? — NewApiKey / HashApiKey#
An API key lets a program call the app's REST API as one person (X-API-Key). Security.NewApiKey() makes a key the
way the platform makes every key; Security.HashApiKey(key) is what is stored for it in this app, on the person's own
row (ApiKeyHash, or ApiKeyHash2 for a second key). Neither writes anything: who may put a hash on whose account is
your app's security {}. You rarely need either directly — [[security-people-kit#api-keys|the UserManagement kit]] issues and
revokes keys from a settings page, through these two:
var key = Security.NewApiKey(); // show it once
var hash = Security.HashApiKey(key); // keep this — a request presenting `key` then acts as its holderBoth run only on the server.
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.
Who is acting, when I have no principal type? — CurrentUserId#
Security.CurrentUserId is the id of the principal the code is running for — the signed-in person, or the actor a
body runs as — and null when nobody is signed in. An app reads the whole row with Session.CurrentUser; a
package cannot, because the [Principal] entity is the app's and the package was written before the app
existed. The id is what a package can hold: a Guid? it can store, compare and hand back.
entity AuditLine {
Guid? Actor;
[MaxLength(200)] string What = "";
}
void Note(string what) { new AuditLine { Actor = Security.CurrentUserId, What = what }; }It runs only on the server: who is acting is the server's to say.
Examples#
The declarations the examples above are written against — the credential entity, the roles, and the wiring that makes
Login/Signup reachable while signed out:
[Role] enum AppRole { Authenticator, Member }
[Principal]
entity User {
// [Unique] or two rows share a login, and which account a password opens is then undefined — a race no
// check-then-insert in application code can close, because the race is in the database.
[Required, Unique, MaxLength(255)] string Email;
[MaxLength(200)] string PasswordHash; // the HASH — the password itself is stored nowhere
security {
allow read when IsAuthenticator;
allow create, update when IsAuthenticator;
allow read where Id == user.Id;
deny read PasswordHash when !IsAuthenticator; // and only the auth flow ever reads even the hash
}
}
[RoleGrant] entity RoleGrant {
[Required] User User;
[Required] AppRole Role = AppRole.Member;
}
entity ApiKey {
[Required] User Owner;
[MaxLength(64)] string Token = "";
security {
allow read, create where Owner == user; // the rows are yours; the SECRET on them is nobody's
// ⛔ A ROW GRANT IS NOT A FIELD GRANT, and "only the owner can read it" is not a reason to skip this. A
// readable stored secret is readable forever, by every later page, export and audit that touches the row.
// `IssueApiKey` hands the value to its caller once; after that there is nothing left to read.
deny read Token;
}
}
policy IsAuthenticator => RoleGrant.Any(g => g.User == user && g.Role == AppRole.Authenticator);
app.AuthBootstrap = new AuthBootstrap {
Role = AppRole.Authenticator,
Login = Login,
Signup = Signup,
PasswordChange = ChangePassword,
};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