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

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

<!-- id: stdlib-security · area: stdlib · stability: stable · html: https://osysharp.com/reference/stdlib/security/ -->

## Summary        {#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:

```osy title="the three that make a login" test app=stdlib-security
[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      {#signature}
```osy syntax
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    {#description}

### How do I hash a password, and check one?   {#passwords}
`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**.

```osy title="✗ two hashes of one password never match — verify instead" syntax
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:

```osy title="a hash is an ordinary bounded string column" syntax
[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)`   {#no-account}

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`:

```osy syntax
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](https://osysharp.com/reference/security/entity-security/) (`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`   {#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)](https://osysharp.com/reference/security/role-grants/)) and it is in force
immediately.

### How do I make an unguessable token? — `RandomId` / `RandomHex`   {#random}
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.

```osy title="an api key nobody can guess" test app=stdlib-security
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 { }`](https://osysharp.com/reference/http/client/) block, 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 `demo/auth-demo` is the same flow 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.

### Has this secret been given a value? — `IsSecretSet`   {#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:

```osy title="degrade with a message, rather than failing where the secret is read" syntax
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       {#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:

```osy title="the app these functions live in" test app=stdlib-security
[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 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;
}

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,
};
```

## See also       {#see-also}
- [[AuthMethod] — a function an unauthenticated visitor may call](https://osysharp.com/reference/security/auth-method/) — the `[AuthMethod]` marker these functions carry
- [auth bootstrap (login, before anyone is signed in)](https://osysharp.com/reference/security/auth-bootstrap/) — the identity they run as, and what it is allowed to touch
- [app.Auth — how the platform authenticates a user of your app](https://osysharp.com/reference/security/password-auth/) — `app.Auth`, where the platform does the hashing and ticket-issuing for you
- [role grants (and the first admin)](https://osysharp.com/reference/security/role-grants/) — granting a role at signup without opening a path to self-elevation
