Serving with authentication
A Hypermake project served on a public origin needs a boundary. Hypermake ships one: a deny-by-default request admission layer that a project turns on by declaring policy files, not by writing middleware. This page documents that layer as it exists in code — what a project declares, what refuses, and how the first session is minted — so you can protect your own served site. Every value shown is a placeholder; no real deployment's identifiers, key paths, or domains appear here.
What turns it on
There is no --auth flag. Serve reads dev.auth from hypermake.json; if the block is present, the boundary is installed in front of every request, and if any part of it is unusable, serve does not start. That is the whole activation contract, and it is deliberately one-way: you cannot half-enable it, and you cannot leave it running degraded.
Startup, in order — any failure exits before the listener opens
| Step | Refuses when |
|---|---|
Decode dev.auth (strict; unknown fields rejected) | A misspelled field, a non-canonical project-relative path, an ingress outside application-unix-socket / control-unix-socket / private-probe, a loginRedirects entry that is not a clean absolute path, or exchangeDirectory declared without loginRedirects (they must be declared together). |
Load the runtime bundle from policy | The deployment policy, its route policy, its cookie policy, or its keyring is missing, malformed, or fails validation. |
| Check the ingress binding | The configured ingress is not listed in the deployment policy's trustedIngresses. Your project and your deployment must agree on how requests arrive. |
| Acquire the session store | Another writer holds it. The serve process is the only writer of a session store; a second one fails closed rather than racing. |
Load projectPolicy (optional) | The project authorization policy is malformed. |
| Prepare device login (optional) | The named environment variable does not hold an absolute path to a usable signer key. |
The four policy files
The dev.auth.policy path points at a deployment policy; that file references the other three plus the session store by relative path. All five live wherever you put them — the substrate has no opinion about layout, only about content.
Deployment policy — hypermake.auth.deployment-policy.v1
| Field | Constraint enforced at load |
|---|---|
mode | Exactly local-owner or production-owner. |
issuer, audience, deploymentInstance | Non-empty, and bound into every token. Local and production deployments must use distinct values even when they share public-key generations — this is what stops a laptop session from being replayed against a public instance. |
algorithm | EdDSA. There is no algorithm negotiation. |
allowedOrigins | At least one, each a bare https origin: no userinfo, no path, no query, no fragment. Plain-HTTP origins are refused. |
trustedIngresses | Subset of application-unix-socket, control-unix-socket, private-probe. |
maxSessionTtlSeconds | 1 to 43200 (12 hours), the substrate's hard ceiling. |
keyringRef, routePolicyRef, cookieRef, sessionStoreRef | All required, resolved relative to the policy file. |
{
"schemaVersion": "hypermake.auth.deployment-policy.v1",
"policyId": "example-deployment",
"generation": 1,
"mode": "production-owner",
"issuer": "example-auth-issuer",
"audience": "example-site",
"deploymentInstance": "example-site-prod",
"algorithm": "EdDSA",
"keyringRef": "keyring.json",
"routePolicyRef": "routes.json",
"cookieRef": "cookie.json",
"sessionStoreRef": "sessions.jsonl",
"allowedOrigins": ["https://site.example"],
"trustedIngresses": ["application-unix-socket"],
"maxSessionTtlSeconds": 43200
}
Cookie policy — every field is a checked posture, not a preference
| Field | Accepted value |
|---|---|
name | Must begin with __Host-. The browser then enforces the host binding for you. |
path | / exactly. |
secure / httpOnly | Both true. There is no way to declare a script-readable session cookie. |
domainAttribute | "absent". Cross-subdomain session sharing is not expressible. |
sameSite | Strict or Lax. |
ttlSeconds | 1 to 43200, and never above the deployment policy's maxSessionTtlSeconds. |
clearOnRefusal | Named causes the boundary may use when it clears the cookie on refusal. |
The keyring (hypermake.auth.keyring.v1) publishes public material only: one or more root keys, and session-signing keys each carrying a delegation signed by a root key with its own expiry and per-session TTL ceiling. Verification code never loads a private key. The session store is an append-oriented file of secret-free session records — subject, instance, signing key id, status, lifetimes, optional parent session — owned exclusively by the serve process.
Route policy: classify before you dispatch
Every request is matched against the route policy before the graph or any handler sees it, so a refusal has no side effects. defaultDecision must be deny; an undeclared route is a 404-shaped refusal, not a fallthrough.
Access classes
| access | Behavior |
|---|---|
public | Admitted with no session. Still subject to CSRF evidence when the route is a mutation. |
authenticated | Requires exactly one session cookie, a verified token, a live durable session, and — if a project policy is loaded — an explicit grant. |
internal-only | Admitted only when the request arrived over an ingress the route and the deployment policy both trust. Otherwise it reports as unreachable, not as forbidden. |
unreachable | Declared and permanently refused — the way to retire a path without deleting the row. |
device-subtree | The one delegating class: the row declares the device prefix once and the canonical dispatcher decides each leaf. It must be a prefix row, a mutation, with exact-origin CSRF. |
matchKind is exact, prefix, or regex, and matching happens on the canonical path only: if the raw path differs from its canonical form in any way — dot segments, encoding differences between the decoded and escaped path — the request is refused as non-canonical before any row is consulted. Method lists are explicit; GET does not imply HEAD unless you say so.
{
"schemaVersion": "hypermake.auth.route-policy.v1",
"policyId": "example-routes",
"generation": 1,
"defaultDecision": "deny",
"routes": [
{ "id": "public-site", "matchKind": "prefix", "path": "/",
"methods": ["GET", "HEAD"], "access": "public",
"mutation": false, "csrf": "none",
"authorizationClass": "site.read" },
{ "id": "admin-console", "matchKind": "prefix", "path": "/admin/",
"methods": ["GET", "HEAD"], "access": "authenticated",
"mutation": false, "csrf": "none",
"authorizationClass": "admin.read" },
{ "id": "admin-mutate", "matchKind": "exact", "path": "/admin/apply",
"methods": ["POST"], "access": "authenticated",
"mutation": true, "csrf": "exact-origin-and-same-origin",
"authorizationClass": "admin.write" }
]
}
For any mutation route the boundary demands two independent pieces of browser evidence before it looks at the session at all: an Origin header that is exactly one of the deployment policy's allowedOrigins, and Sec-Fetch-Site: same-origin. Host headers, forwarded headers, source IP, and the spelling of localhost are never inputs to any of this.
Project authorization: who, not just whether
Authentication answers "is this a real session". The optional projectPolicy answers "may this subject use this route class". It is a default-deny list of grants mapping subjects to the authorizationClass values your route policy already declares — exact string matching, no wildcards, no roles baked into the token. Project vocabulary stays in project policy; the session claims stay generic.
{
"schemaVersion": "hypermake.auth.project-authorization-policy.v1",
"policyId": "example-grants",
"generation": 1,
"defaultDecision": "deny",
"grants": [
{ "id": "owner-full",
"subjects": ["owner@example"],
"authorizationClasses": ["admin.read", "admin.write"] }
]
}
Before a request reaches your handler the boundary also strips any client-supplied identity headers — Authorization, X-Forwarded-User, X-Authenticated-User, X-Hypermake-Auth-Subject — and attaches the verified identity to the request context instead. Nothing downstream can be fooled by a header it did not earn.
Minting the first session
There is no signup form and no password. Authority starts with one owner-confirmed ceremony run on the host, and reaches the browser through a short-lived, single-use exchange.
EXAMPLE_AUTH_KEYS=/absolute/owner-only/key/dir \ hypermake auth owner-ceremony \ --request auth/example/ceremony-request.json \ --confirm-owner-ceremony
The confirmation flag is mandatory: without it the command refuses with auth.ceremony_owner_confirmation_required and does nothing. The request file names the deployment policy, the keyring output, the exchange directory, the subject, the post-login redirect path, and four lifetimes — root, delegation, session, and exchange — each bounded, with delegation never outliving root and session never outliving delegation or the policy ceiling. The exchange lifetime is capped at 300 seconds.
What one ceremony produces
| Artifact | Where and who may read it |
|---|---|
| Root private key, session-signer private key | The owner-only key directory named by the environment variable. Never referenced from config, never loaded by verification code. |
| Root public key, signer public key, keyring JSON | Beside the deployment policy. Public material, published deliberately. |
| One pending exchange artifact | Mode-0600 in the exchange directory: the minted token plus a SHA-256 digest of the proof — never the proof itself. |
| stdout | Exactly two values: exchangeId and proof. No key, no token, no path, no instance metadata ever reaches stdout. |
You then hand those two values to the running deployment as a form post from your own browser:
Content-Type: application/x-www-form-urlencoded exchangeId=exchange_EXAMPLE&proof=EXAMPLE_PROOF&returnTo=/admin/
Exactly three form fields, no query string, an 8 KiB body cap, and returnTo must be one of the loginRedirects you declared. On success the serve process installs the durable session, writes a secret-free receipt, deletes the pending artifact, and sets the session cookie — the token itself never becomes browser-visible state, and the exchange cannot be redeemed twice. A crash mid-flight can orphan a revocable session, but it cannot permit a replay.
Later, while the delegation is still valid, hypermake auth owner-exchange-refresh --confirm-owner-exchange-refresh stages a fresh five-minute exchange against the standing signer: no key rotation, no serve restart, no session-store write, and any previously pending window is atomically replaced. It requires the same owner-only custody, and it never loads the root key.
Runtime endpoints the boundary owns
Reserved paths on an auth-enabled deployment
| Path | Method | Behavior |
|---|---|---|
/auth/session/exchange | POST | Redeems one owner exchange, sets the cookie, redirects to returnTo with Cache-Control: no-store and Referrer-Policy: no-referrer. |
/auth/sessions/current | GET | Secret-free session status: session id, subject, deployment instance, issued/expires. Declare it as an authenticated route. |
/auth/logout | POST | Revokes the durable session and clears the cookie. Requires an admitted session and verified CSRF evidence. |
/auth/device/… | varies | The QR device subtree, when dev.auth.device is declared: pairing start / status / finalize are public but same-origin; approve, deny, list, and revoke require an admitted owner session. |
Device login exists so a second device can be admitted by an already-trusted one instead of by copying a token around. The flow is bounded on purpose: a pairing request lives 1–600 seconds (ten minutes is the hard ceiling), session TTL stays under the policy ceiling, at most 32 requests may be pending at once, and finalize is atomic and rechecks the approving parent. A revoked approver or a signer error is terminal. Revoking a child never touches its parent.
The refusal contract
Refusals are data, not prose. Every one is a JSON envelope with a stable reason code and a message that deliberately says less than the code does — the code is for you, the message is for the browser.
{
"schemaVersion": "hypermake.auth.refusal.v1",
"requestId": "auth-example-serve-id-1",
"reasonCode": "auth.token_missing",
"stage": "authentication",
"httpStatus": 401,
"retryable": false,
"message": "Authentication proof is invalid."
}
Reading the codes
| Family | Meaning and typical cause |
|---|---|
auth.route_path_noncanonical, auth.route_undeclared, auth.route_unreachable | Canonicalization stage. The path was not canonical, no row matched, or the row is unreachable from this ingress. Nothing was authenticated yet. |
auth.token_missing, auth.claims_invalid, auth.claim_binding_invalid, auth.claim_lifetime_invalid | Authentication stage. Zero or multiple session cookies, a token that fails strict verification, or claims bound to a different issuer/audience/instance/key generation. |
auth.session_unknown, auth.session_expired, auth.session_revoked | The token verified but the durable session does not admit it. These four causes also clear the cookie — expired, revoked, unknown-session, wrong-instance — so a stale browser stops resending a dead credential. |
auth.origin_invalid | CSRF stage: the mutation lacked an allowed Origin or same-origin fetch evidence. |
auth.ceremony_*, auth.exchange_*, auth.device_* | Ceremony, one-time exchange, and device-flow refusals. auth.exchange_consumed in particular is the correct, expected answer to a replayed login link. |
auth.policy_unavailable, auth.*_unavailable | The only retryable: true family — status 503, meaning the boundary could not evaluate, so it refused rather than guessed. |
Admitted authenticated responses also carry Cache-Control: no-store and Vary: Cookie, plus Vary: Origin, Sec-Fetch-Site on mutations, so no shared cache can serve one session's page to another visitor.
Protecting your own served site
Order of operations
| # | Step |
|---|---|
| 1 | Choose an issuer, audience, and deployment instance that no other deployment of yours uses — including your laptop's. |
| 2 | Write the deployment, route, and cookie policies. Start with everything authenticated and open exactly the routes you mean to be public. |
| 3 | Create an owner-only, non-symlinked key directory with no group or world permissions, and export its absolute path as the environment variable your ceremony request names. |
| 4 | Run hypermake auth owner-ceremony with the confirmation flag. Keep the printed proof out of shell history and logs; it is single-use and expires within five minutes. |
| 5 | Add dev.auth to hypermake.json — policy, ingress, and (if you want browser login) exchangeDirectory plus loginRedirects together. See the hypermake.json reference for the exact field shape. |
| 6 | Start serve. If it exits, read the message: a refusing boundary is the feature. Redeem the exchange from your browser, then confirm with /auth/sessions/current. |
| 7 | Add projectPolicy once more than one subject exists, and use owner-exchange-refresh rather than a second ceremony when you simply need a new login link. |
hypermake auth owner-ceremony hypermake explain concept://protocol hypermake resolve src://docs/serve-auth.html hypermake affected-by src/docs/serve-auth.html
The first command is worth running before anything is configured: with no confirmation flag it refuses immediately with auth.ceremony_owner_confirmation_required, which is the shortest possible demonstration of how this whole layer behaves.