README
¶
net/http example
A single-process application that embeds Sigil in a server it owns, so the library's boundaries are visible in real code: the host keeps the listener, the router, the database pool and shutdown; Sigil owns configuration validation, the mounted authentication API, session verification and abuse admission.
Run it two ways. Without a database it exercises transport policy and the fail-closed paths. With PostgreSQL it runs a complete lifecycle: sign in, read the session, list sessions, renew, step up, revoke.
Which example do you want?
| Use | |
|---|---|
| Getting started, or adding Sigil to your own service | Installation and quickstart |
| The complete browser journey with real library routes | examples/browser |
| A deployment-shaped service: a third-party router, the background workers and shutdown | examples/router |
| Server-side rendering with React | examples/react |
| This example: how a host composes the low-level primitives itself | below |
The
/demo/*routes are superseded — do not copy themWhen this example was written, the library had no credential routes, so it built registration and sign-in out of primitives to show how they fit together. The library now owns those flows:
POST /register,POST /verify-email,POST /sign-in/passwordandPOST /logoutare part of the supported API, with email verification, uniform failures and transactional session issuance that a host cannot reproduce from outside.The handlers stay here because the individual primitives they use — shared abuse budgets, Argon2id hashing with revision-checked upgrades, bounded dummy work, keeping the KDF outside the transaction — are still exactly how you would use those primitives in your own code. The endpoint they add up to is not. In particular it answers
409 email_taken, which tells an attacker that an address is registered, and it issues the session in a transaction after the one that verified the password. Use the library's routes.They are registered only on a loopback origin with storage configured.
What it demonstrates
| Behavior | Where | Reference |
|---|---|---|
Validated, frozen configuration and the loopback Development exception |
app.go newApplication |
core contracts |
| Host-owned secret loading, never a literal in source | app.go deploymentSecret |
threat model |
| Borrowed database pool, explicit operator migrations, read-only startup check | app.go openDatabase |
migrations |
| Mounting the authentication API under a prefix | routes.go handler |
HTTP security |
RequireSession on host routes, with host-owned CSRF and origin policy |
routes.go handler, me |
sessions |
Credential-free request snapshots (UserFromContext) |
routes.go me |
sessions |
Step-up with RequireRecent, which renewal never refreshes |
routes.go sensitive |
sessions |
| Error mapping that serializes only library-controlled categories | routes.go writeError |
HTTP security |
Shared budgets before work, with expensive=false around Passwords() |
signin.go register, signIn |
abuse controls |
| Argon2id hashing and verification, with revision-checked upgrades | signin.go authenticate, finishVerification |
passwords |
| Bounded dummy work so timing does not disclose account existence | signin.go authenticate |
passwords |
| KDF outside the transaction; proof revalidated under the user lock | signin.go authenticate |
storage contracts |
| Session delivery through the cookie policy, never a serialized token | signin.go signIn |
sessions |
SafeRedirect for post-authentication destinations |
signin.go signIn |
HTTP security |
| Direct Go calls enforcing the same authorization as HTTP | main.go checkDirectCalls |
core contracts |
Graceful shutdown: drain requests, close Auth, then the pool |
main.go run, app.go close |
core contracts |
A storage.AbuseStore implementation and its atomicity contract |
limiter.go |
abuse controls |
Requirements
Go 1.27.1 or newer. PostgreSQL is optional; make pg-up starts the same
disposable, pinned container the integration checks use. No provider account,
mail credential or environment file is needed.
Without a database
go build -o bin/nethttp ./examples/nethttp
./bin/nethttp
The server logs that it generated an ephemeral development secret, that it is using an in-process development limiter, and that storage-backed routes will fail closed. In another terminal:
curl http://127.0.0.1:8080/ # route map for the current mode
curl http://127.0.0.1:8080/healthz # ok
curl -i http://127.0.0.1:8080/readyz # 503 unavailable: no storage configured
curl -i http://127.0.0.1:8080/api/auth/session # 401 unauthenticated
curl -i http://127.0.0.1:8080/api/auth/nope # 404 not_found
curl -i -X DELETE http://127.0.0.1:8080/api/auth/session # 405 with Allow
curl -i http://127.0.0.1:8080/app/me # 401 unauthenticated
Every response carries Cache-Control: no-store, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer and Vary: Origin. Session renewal,
listing and revocation answer 503 unavailable in this mode: without a store
they fail closed rather than guessing.
Note what makes the routes reachable at all. Without limiter storage the
handler rejects every request with 503 abuse_unavailable, so this mode
supplies an in-process storage.AbuseStore (limiter.go). That stand-in is
correct for one process on loopback and wrong for a deployment: replicas would
each keep private counters, handing an attacker one full budget per replica.
The library has no memory fallback on purpose.
With PostgreSQL
make pg-up # 127.0.0.1:55432, user/password/database auth_ci
go build -o bin/nethttp ./examples/nethttp
./bin/nethttp --database 'postgres://auth_ci:auth_ci@127.0.0.1:55432/auth_ci?sslmode=disable' --migrate
--migrate applies the core migrations and is an operator action; drop it on
later starts and the read-only startup check reports the exact problem
(core migrations pending; rerun with --migrate) instead of a generic failure.
The operator CLI does the same from outside the process:
sigil migrate apply with the connection string in SIGIL_DATABASE_URL, and
sigil check --base-url ... diagnoses the deployment before the server starts.
curl http://127.0.0.1:8080/readyz now returns {"status":"ready"}.
The walkthrough below is a real session, with identifiers and timestamps from a
sample run. -c/-b keep a cookie jar, exactly as a browser would.
Register. The address is canonicalized; the password is not.
$ curl -s -X POST http://127.0.0.1:8080/demo/register -H 'Content-Type: application/json' \
-d '{"email":"Ada@Example.com","password":"correct horse battery staple"}'
{"user":{"id":"2b9cf0cdf54522b32ecdde21f419b00f","email":"ada@example.com","emailVerified":false,"createdAt":"2026-09-14T11:19:40.242858Z"}}
emailVerified stays false: these handlers never send mail. Registering the
address again returns 409 email_taken, which a real endpoint must not do —
see Limitations.
Sign in. Wrong passwords and unknown addresses share one answer, and the work is comparable either way, so the response time does not disclose whether an account exists:
$ curl -s -o /dev/null -X POST .../demo/sign-in -d '{"email":"ada@example.com","password":"wrong password here"}' \
-H 'Content-Type: application/json' -w '%{http_code} %{time_total}s\n'
401 0.049161s
$ curl -s -o /dev/null -X POST .../demo/sign-in -d '{"email":"nobody@example.com","password":"wrong password here"}' \
-H 'Content-Type: application/json' -w '%{http_code} %{time_total}s\n'
401 0.049207s
A correct password issues a session. The bearer is delivered only as a
HttpOnly cookie; the body carries the public view and a validated redirect:
$ curl -s -c jar.txt -X POST http://127.0.0.1:8080/demo/sign-in -H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"correct horse battery staple","next":"/app/me"}'
{"redirect":"http://127.0.0.1:8080/app/me","view":{"user":{...},"session":{"id":"943f58d0...","state":"authenticated",...}}}
next must be a local absolute path. https://evil.example.com/ and
/app/../../etc are rejected with 400 bad_request, before any session is
issued or any cookie is set.
Use the session. The host route and the library route agree, because both revalidate against authoritative storage:
curl -b jar.txt http://127.0.0.1:8080/app/me # user + session snapshot
curl -b jar.txt http://127.0.0.1:8080/api/auth/session # session view
curl -b jar.txt 'http://127.0.0.1:8080/api/auth/sessions?limit=10' # active sessions, ID order
curl -b jar.txt -X POST http://127.0.0.1:8080/app/sensitive # step-up check
/app/sensitive requires authentication newer than two minutes and answers
403 recent_authentication_required after that, no matter how often the
session is renewed.
CSRF and mutations. Mutating library routes require proof bound to the browser and the current session cookie, so refetch it after signing in:
$ TOKEN=$(curl -s -b jar.txt -c jar.txt http://127.0.0.1:8080/api/auth/csrf | jq -r .token)
$ curl -s -b jar.txt -X POST http://127.0.0.1:8080/api/auth/session/renew -H 'Content-Type: application/json' -d '{}'
{"code":"forbidden","message":"operation forbidden"}
$ curl -s -b jar.txt -c jar.txt -X POST http://127.0.0.1:8080/api/auth/session/renew \
-H 'Content-Type: application/json' -H "X-CSRF-Token: $TOKEN" -d '{}'
{"user":{...},"session":{...}}
Renewal is a no-op before RenewAfter (one minute here, one day by default)
and never happens on a GET.
Revoke. POST /api/auth/session/revoke returns 204, deletes the cookie,
and the next GET /api/auth/session is 401. POST /api/auth/sessions/revoke
invalidates every session of that user through the authentication epoch.
Rate limiting. Every admitted attempt is charged, successes included:
$ for i in 1 2 3 4 5 6; do curl -s -D- -o /dev/null -X POST .../demo/sign-in -H 'Content-Type: application/json' \
-d '{"email":"lin@example.com","password":"wrong password here"}' | grep -iE '^HTTP|^retry-after'; done
HTTP/1.1 401 Unauthorized
HTTP/1.1 401 Unauthorized
HTTP/1.1 401 Unauthorized
HTTP/1.1 401 Unauthorized
HTTP/1.1 401 Unauthorized
HTTP/1.1 429 Too Many Requests
Retry-After: 60
This example tightens sign-in to 20 attempts per IP and five per address per minute (the defaults are 30 and five). Windows last one minute from the first admitted attempt; a rejected attempt does not extend them. Session bearers are digested without the deployment secret, so sessions survive a restart even though the ephemeral development secret changes — CSRF tokens do not.
Configuration
| Flag | Environment | Default | Purpose |
|---|---|---|---|
--addr |
— | 127.0.0.1:8080 |
Listen address; 127.0.0.1:0 selects a free port |
--base-url |
— | http://<listen address> |
Public origin without the mount path |
--database |
SIGIL_DATABASE_URL |
empty | PostgreSQL DSN; empty means storage-backed routes fail closed |
--schema |
— | public |
Schema holding the core tables |
--migrate |
— | false |
Apply pending core migrations, then serve |
--trusted-origins |
— | the base URL | Comma-separated same-site origins allowed to call the API |
--trusted-proxies |
— | none | Comma-separated CIDRs allowed to set X-Forwarded-* |
| — | SIGIL_SECRET |
ephemeral in development | At least 32 bytes of high-entropy key material |
A non-loopback or HTTPS --base-url turns development mode off: SIGIL_SECRET
becomes mandatory, cookies become Secure with the __Host- prefix, and
plain HTTP is rejected. Cross-site deployment is unsupported; --trusted-origins
accepts sibling origins on the same registrable domain.
What a deployment must change
- Credential routes. Use the library's
POST /register,POST /verify-email,POST /sign-in/passwordandPOST /logoutrather than/demo/*, which have no CSRF proof, no origin check, no email verification and a non-uniform registration response. - Limiter storage. Supply a shared store. The in-process limiter is for one loopback process.
- Secret. Provide
SIGIL_SECRETfrom a real secret source. Every replica needs the same secret, base URL and policies, or abuse budgets diverge. - Session windows. This example uses 24 hours absolute, one hour idle and one minute between renewals so the behavior is observable. The defaults are 30 days, seven days and one day.
- Client IP.
clientIPinroutes.gotrusts the connection peer, which is correct only when the process is directly exposed. Behind a proxy, apply the same forwarding discipline the library applies withTrustedProxies. - Migrations. Run them as a separate operator step with a role that has DDL
rights (
sigil migrate apply, operator CLI), not with--migrateon a serving process.
Limitations
These describe the /demo/* handlers, not the library, and they are the reason
those handlers are superseded:
Admin().CreateUserreturnsunavailable, socreateAccountwrites the identity and credential through the publicstoragecontract instead. That contract is explicitly unstable until AUTH-051.- The example verifies a password, revalidates the proof under the user lock, and then issues the session in a following transaction, because the transactional issuance helper is library-internal. A password reset that commits in that window would be honoured as the new epoch. The library's own sign-in issues inside the proof transaction, which a host cannot do.
- A real registration endpoint must answer uniformly instead of returning
409 email_taken, which confirms that an address is registered. - There is no mail, no email verification and no password reset here. The library provides all three; see credentials.
The example itself is also deliberately narrow: no browser UI, one process, no plugins, and an in-process limiter that is correct only on loopback.
Tests
go test ./examples/nethttp/
The tests run the storage-free configuration: transport policy, the CSRF
bootstrap, fail-closed session routes, origin classification and the limiter's
atomicity, window reset and cancellation contract. Flows that need a database
are covered by the storage and adapter suites, and end to end by
examples/browser.
Documentation
¶
Overview ¶
Command nethttp demonstrates embedding Sigil in an application-owned net/http server: validated configuration, the mounted authentication API, host routes protected by RequireSession, trusted host capabilities, abuse admission and graceful shutdown. Run it with --database to exercise real sessions, or without one to observe the storage-free failure modes. See README.md; the credential routes are a composition demonstration, not the library sign-in flow that AUTH-017 will own.