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

Reference / Stdlib

Security.* — hashing, verifying, tickets, random ids

Security.HashPassword(plain) · VerifyPassword(plain, hash) · VerifyPassword(plain) · IssueJwt(userId, email) · RandomId() · RandomId(length) · RandomHex(length)

The calls an authentication flow needs: `HashPassword` (salted, one-way), `VerifyPassword` (constant-work comparison against a stored hash, and a one-argument form for the no-such-account path), `IssueJwt` (the session ticket a login returns), and `RandomId`/`RandomHex` for unguessable tokens. They are the building blocks of an `[AuthMethod]` login/signup — you never store a plaintext password, and you never compare hashes yourself.

stable3 examples compiled by CIsecuritystdlibauthentication

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 characters

Description#

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 password

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

osy 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. IssueJwt does 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 stops Login from 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#

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…

auth bootstrap (login, before anyone is signed in)

Under deny-all, login faces a paradox: it must read a user row *before* anyone is authenticated. `app.AuthBootstrap`…

app.Auth — how the platform authenticates a user of your app

`app.Auth` binds two properties of your `[Principal]` — which one is the login, which one holds the password hash — and…

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…