Help us shape the future of Go! Take the 2026 Go Developer Survey

nethttp

command
v0.1.0-alpha.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 23, 2026 License: MIT Imports: 28 Imported by: 0

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 them

When 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/password and POST /logout are 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/password and POST /logout rather 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_SECRET from 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. clientIP in routes.go trusts the connection peer, which is correct only when the process is directly exposed. Behind a proxy, apply the same forwarding discipline the library applies with TrustedProxies.
  • Migrations. Run them as a separate operator step with a role that has DDL rights (sigil migrate apply, operator CLI), not with --migrate on a serving process.

Limitations

These describe the /demo/* handlers, not the library, and they are the reason those handlers are superseded:

  • Admin().CreateUser returns unavailable, so createAccount writes the identity and credential through the public storage contract 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL