The browser only ever talks to the UI: server-rendered pages, Datastar actions that answer with HTML fragments over SSE, and the embedded static assets. Nothing it receives names the JSON API.
- The JSON API is not for browsers.
/api/auth/*,/api/core/*and/healthzanswer any request that carries browser headers (Sec-Fetch-*,Origin,Referer, orAccept: text/html) with the same bare 404 as an unknown path, and never send CORS headers. A wrong method is a 404 too (no405+Allow). The mobile app (native fetch) and servers send none of these. The one exception: a top-level navigation to auth's two link endpoints when an emailed link's callback is a deep link or another origin (mobile); they only redirect, and an error they would answer as JSON is the same 404. Emailed links (verify, reset, invite) open UI pages directly, and opening one spends nothing: mail scanners and link previews fetch links, so each page asks for a click and only that POST spends the token (the page carries it only as a hidden form field, withno-storeand, behind https,no-referrer). The mobile app's verification links use the same page, which then hands over to the app's deep link. - Browser interactions are server actions. Forms and Datastar actions post to UI routes (CSRF-protected by Go's cross-origin protection), and those call the JSON API in-process through
apiclient.Client, so the UI is held to the API's access policies and validation without exposing the API to the browser. Lint keeps it that way: noconfig.APIRoutesor HTTP client in UI code, andscripts/check-ui-api-paths.shfails on any/api/path in UI templates, code or assets. - The admin app has no JSON API on the network. Its UI calls its API in-process, sign-in and 2FA included;
/api/admin/v1exists as code (apps/admin/internal/api, tested) butcmd/adminmounts none of it and no admin auth endpoint. To let scripts call it, pass its routers asserver.Deps.API(browsers still get a 404). - Headers on every UI response: a nonce-based CSP (
script-src 'self' 'nonce-…',style-src 'self',frame-ancestors 'none', nounsafe-*: Datastar compiles expressions into nonced scripts from<html data-nonce>),X-Content-Type-Options,X-Frame-Options,Referrer-Policy: strict-origin,Permissions-Policy, COOP/CORP,Cache-Control: no-storeon pages and SSE, and HSTS behind https. Templates never use inlinestyle=oron*=attributes (show/hide withclass="hidden"+data-class:hidden). The CSP keeps out foreign scripts, styles, frames and form targets, but it is not the XSS defence: Datastar runs everydata-*expression it finds with the page's nonce, so injected markup would run. templ's escaping is the defence; nothing may bypass it (templ.Raw,templ.Unsafe*,templ.SafeScript*, ...), and redirect targets from the query go throughsafeNext. - Errors are generic. Panics answer a plain 500 (the envelope on the API); unknown service errors show "Something went wrong" and are logged; validation messages name JSON types, never Go types.
- Cookies are
HttpOnly,SameSite=LaxandSecurebehind https; the admin session uses the__Host-prefix (the web app keeps Better Auth's__Secure-names for the mobile client). - Secrets are
secrets.Secretvalues from config to the client that uses them (see Secrets); logs are redacted.
Enforced by lint, not review: depguard keeps UIs from importing their JSON API or packages/secrets, and keeps packages/secrets/reveal (reveal.Value, the only way to read a secret: Secret has no method that returns it) in cmd (make lint-layering proves both bite, and that reading a secret around reveal.Value does not compile). Tests live only in the services, route handlers and packages/auth (AGENTS.md "Test boundaries").