---
slug: 40a-web-auth
title: Login & sessions (web auth)
description: Everything a browser login needs — session cookies with safe defaults, password hashing (argon2id), signed tokens (JWT HS256), 2FA TOTP, CSRF and a gated CSPRNG.
example_ids: [web-auth]
---

# Login & sessions (web auth)

> **Engine v0.5.5+.**

Web auth in Synsema is a small set of primitives, each with the safe choice as the default. No framework, no middleware — a login flow is a handful of lines in a `serve` block.

```synsema
-- Doc example: web auth primitives — password hashing, JWT, TOTP and the CSPRNG.
-- (Cookies/sessions ride on `serve`, which doesn't terminate — the serve page shows
-- the full login flow; this doctest asserts the primitives that flow is built from.)
intent: "doc example: web auth primitives"
require random

let phc be password_hash("hunter2")
print("phc → " + slice(phc, 0, 10) + "…  verify → " + text(password_verify("hunter2", phc)))
print("session id → " + token())

test "password_hash gives a PHC string; verify is strict, wrong password is false"
    let phc be password_hash("hunter2")
    assert(starts_with(phc, "$argon2id$"))
    assert_eq(password_verify("hunter2", phc), true)
    assert_eq(password_verify("wrong", phc), false)

test "token() is 43 chars of base64url — 32 CSPRNG bytes, fine for session ids"
    assert_eq(length(token()), 43)
    assert_eq(length(random_bytes(16)), 16)

test "jwt: sign/verify roundtrip; wrong key or tampering → nothing"
    let tok be jwt_sign({"sub": "u1", "role": "admin"}, "k", {"expires_in": 3600})
    let claims be jwt_verify(tok, "k")
    assert_eq(claims.sub, "u1")
    assert(claims.exp > claims.iat)
    assert_eq(jwt_verify(tok, "other-key"), nothing)
    assert_eq(jwt_verify(tok + "x", "k"), nothing)

test "totp matches the RFC 6238 vector and verifies within the window"
    let seed be bytes("12345678901234567890")
    assert_eq(totp(seed, {"digits": 8, "at": 59}), "94287082")
    assert_eq(totp_verify(seed, "94287082", {"digits": 8, "at": 59}), true)
    assert_eq(totp_verify(seed, "00000000", {"digits": 8, "at": 59}), false)

test "base64url differs from base64: URL-safe alphabet, no padding"
    assert_eq(decode(bytes([251, 255]), "base64url"), "-_8")
    assert_eq(decode(bytes([251, 255]), "base64"), "+/8=")
    assert_eq(bytes("-_8", "base64url"), bytes([251, 255]))
```

## Session cookies

```synsema
require serve(8080)
require random                          -- gates token()/random_bytes

task check_session(token, request)      -- 2 params → also receives the request
    let sid be request.cookies.sid
    when sid == nothing
        give nothing                    -- nothing → 401
    give state_get("sess:" + sid)       -- the value lands in request.user

serve on 8080
    auth with check_session
    route "POST /login"
        when password_verify(request.json.pw, stored_phc)
            let sid be token()
            state_set("sess:" + sid, {"name": request.json.user})
            give set_cookie(ok({"ok": true}), "sid", sid, {"max_age": 86400})
        give fail(401, "bad credentials")
    route "GET /me" requires auth
        give request.user
    route "POST /logout" requires auth
        give clear_cookie(ok({"bye": true}), "sid")
```

- `set_cookie(resp, name, value, opts?)` wraps **any** response value. Defaults are the safe ones: `Path=/; Secure; HttpOnly; SameSite=Lax`. Opts: `max_age` (seconds), `path`, `domain`, `secure`, `http_only`, `same_site` (`"Strict"|"Lax"|"None"` — `None` demands `secure: true`).
- `clear_cookie(resp, name, opts?)` — `path`/`domain` must match the ones used at set time.
- `request.cookies` — incoming cookies as a map (undecoded; no header → empty map).
- `with_header(resp, name, value)` — any extra response header; calls accumulate, repeated names emit separate lines (that's what multiple `Set-Cookie` needs). Framing/hop-by-hop headers and CR/LF are **hard errors** — header injection can't be written.
- An auth task with **1 parameter** keeps the historical contract (bearer token only).

## Passwords, tokens, 2FA

```synsema
require random

let phc be password_hash(pw)            -- "$argon2id$…" (OWASP params) — store as-is
password_verify(pw, phc)                -- bool; corrupt/unknown hash → error, not false

let sid be token()                      -- 32 CSPRNG bytes as base64url (43 chars)
let tok be jwt_sign({"sub": id}, secret("JWT_KEY"), {"expires_in": 3600})
jwt_verify(tok, secret("JWT_KEY"))      -- claims map, or nothing on ANY failure

let seed be random_bytes(20)            -- TOTP enrolment
totp(seed)                              -- current 6-digit code (sha1/30s profile)
totp_verify(seed, submitted)            -- ±1 period window, constant-time
```

- `random_bytes`/`token` need **`require random`** — the same deny-by-default gate as `random()`. The pure transforms (`password_hash`, `jwt_*`, `totp*`) need no capability.
- `jwt_verify` pins the algorithm to HS256 — an `alg: "none"` or RS256 token is rejected, and every failure (signature, `exp`, malformed) is the same `nothing`. RS256/ES256 (third-party OIDC) is not supported yet.
- The TOTP secret is **bytes**: a base32 secret from a QR app decodes with `bytes(s, "base32")`.
- CSRF for cookie-based POSTs: issue `token()` per session, embed it in the form, compare with `constant_time_eq` in the handler.
- Never store live API keys as-is: store `sha256(key)` and compare hashes.

Key/password arguments accept a sealed [`secret`](/en/0.6.x/21-secrets), text or bytes.
