README is now a landing-page-style entry point: centered hero banner,
badges, tagline, feature grid, architecture diagram, self-hosting
table, stack rationale, quick start, project layout, testing summary,
docs index. Image placeholders reference docs/assets/{banner,
dashboard-preview,admin-preview}.png with sizing guidance in
docs/assets/README.md.
docs/INTERNAL_API_AUTH.md walks through the bearer-token model the
worker uses to call /api/v1/internal/* endpoints: where the token
lives on both sides, the constant-time compare, fail-closed semantics
when INTERNAL_API_TOKEN is unset, and the planned per-worker JWT
upgrade path.
docs/VENDOR_LOCKIN.md is the honest scorecard: each external
dependency (AWS KMS, DynamoDB, S3, Cloud Tasks, Stripe, Turnstile,
Sentry) gets a row covering self-host status, alternatives, and what
'free of this dependency' actually looks like. Includes the minimum-
viable self-host env-var set.
4.9 KiB
Internal API Auth
The backend exposes a set of internal endpoints under /api/v1/internal/* that
workers and other backend processes call. These are not user-facing — they sit
behind a dedicated bearer-token middleware so workers can fetch encrypted DEKs,
their runtime config, and report heartbeats without ever opening a Postgres
connection of their own.
Endpoints
| Method | Path | Caller | Purpose |
|---|---|---|---|
| GET | /api/v1/internal/dek/:userID |
worker | Fetch a user's encrypted DEK |
| PUT | /api/v1/internal/dek/:userID |
worker (rarely) | Store a new encrypted DEK |
| DELETE | /api/v1/internal/dek/:userID |
admin / cleanup | Delete an encrypted DEK |
| GET | /api/v1/internal/worker/config |
worker | Fetch runtime config for the worker |
| POST | /api/v1/internal/worker/heartbeat |
worker | Liveness report |
How auth works
A single static bearer token, sourced from the INTERNAL_API_TOKEN environment
variable on the backend, gates every endpoint in the group. Workers send the
same token in Authorization: Bearer ….
Worker Backend
------ -------
GET /api/v1/internal/dek/<uuid>
Authorization: Bearer <token> --->
middleware.InternalAuthMiddleware
└─ subtle.ConstantTimeCompare token
if OK -> handler runs
if NOT -> 401 Unauthorized
<--- 200 OK { "encrypted_data_key": "..." }
or 401 Unauthorized
or 404 Not Found (no DEK)
Fail-closed
If INTERNAL_API_TOKEN is unset on the backend, the middleware rejects every
request with 401. This is deliberate: workers must not be able to read DEKs
from a misconfigured server that silently accepts unauthenticated calls.
Timing-safe compare
The token check uses crypto/subtle.ConstantTimeCompare so that an attacker
can't recover the token byte-by-byte via timing differences.
Where the token lives
| Process | Env var | Used for |
|---|---|---|
| Backend | INTERNAL_API_TOKEN |
Validates incoming worker requests |
| Worker | ENCRYPTED_KEYS_WORKER_TOKEN |
Sends in Authorization header |
Both must hold the same string. In the self-hostable installer, the operator
sets INTERNAL_API_TOKEN once on the backend and passes the same value as
ENCRYPTED_KEYS_WORKER_TOKEN to each worker via the install script.
A reasonable token is 32 bytes of base64:
openssl rand -base64 32
Treat it like a password. Rotate by:
- Set the new token as a secondary value the backend will also accept (not yet implemented — for now, plan a short coordinated swap window).
- Roll the workers (
install-worker.sh --updateafter updating the env). - Remove the old token from the backend.
Why a shared token (for now)
It's the simplest primitive that satisfies the constraint: workers reach backend endpoints without touching the user-facing session/cookie auth. Workers are not "users" — they don't have a UI session, OAuth flow, or refresh token cycle. A shared bearer satisfies the "internal service identity" use case without overbuilding.
Planned: per-worker JWTs
The shared bearer is convenient but coarse. The follow-up plan, tracked alongside the worker config endpoint:
- Each worker registers once via
POST /api/v1/worker/registerwith the shared bootstrap token. - The backend mints a per-worker JWT signed with a backend-private key. The JWT carries the worker ID and tier in its claims.
- Workers send the JWT instead of the bootstrap token on all subsequent calls. The middleware verifies the signature and reads claims.
- The bootstrap token becomes a one-shot used only on first install.
Benefits: ability to revoke a single worker without rotating the whole fleet, audit-log a specific worker's actions, and tier-gate DEK access (e.g., a free-tier worker can only request DEKs for free-tier users).
Implementation lives behind Task #9 in the project tracker.
Where it lives in the code
- Middleware:
internal/api/middleware/internal_auth.go - Handlers:
internal/api/handler/internal_dek.go,internal/api/handler/internal_worker_config.go - Routes:
internal/api/routes.go(search for/api/v1/internal) - Worker-side client:
internal/infrastructure/encryptedkeys/http.go - Tests:
internal/api/middleware/internal_auth_test.go,internal/api/handler/internal_dek_test.go