Engine Auth: better-auth JWT via JWKS, Rate Limiter, Quota Middleware
nestr intermediate 5 min read
ELI5
The web app is the passport office: it signs a 15-minute pass naming you, your organisation and your plan. The engine checks the signature against the office’s public stamp list and checks that the pass was meant for the engine. The turnstile behind it makes fast callers wait their turn rather than turning them away.
Technical Deep Dive
Source: nestr-tools/engine internal/auth/jwt.go:21-108, internal/auth/middleware.go:9-60, internal/server/rest.go:25-48,100-105,140-144,282-295,529-536, internal/server/usage.go:80-110 @e9180e4; nestr-tools/web src/lib/auth.ts:49,170-200 @6f42cca; nestr-tools docs/decisions/2026-10-12-phase-12-launch-scope.md §6 (closed) @b8f0585; read 2026-10-25.
Token path
sequenceDiagram participant B as Browser participant W as web, better-auth participant E as engine participant K as JWKS endpoint on web B->>W: session cookie W->>W: jwt plugin signs EdDSA, exp 15m W-->>B: JWT with sub, email, org, plan, plan_source B->>E: Authorization Bearer JWT E->>K: fetch and cache keys from JWKS_URL E->>E: verify EdDSA, iss, aud nestr-engine, exp, 30s leeway E-->>B: 200, or 401 unauthorized envelope- Issuer default
https://app.nestr.tools, compared exactly after trimming a trailing slash (ENGINE_JWT_ISSUERoverrides); audience defaultnestr-engine(ENGINE_JWT_AUDIENCE). OnlyEdDSAis accepted, andexpis required (jwt.go:43-86). - The web plugin issues one token with two audiences, the registry’s and the engine’s (
auth.ts:170-177). org,plan(communityorearly_access),plan_sourceandplan_confirmed_atare set by web’sdefinePayloadafter a live membership check. The engine trusts them only because the signature verified, uses them only for Community caps and security-record history, and treats a missing or unknown plan as Community. Verification never reads them (nestr-024).- Auth is better-auth in
web/, a recorded deviation from the estate’s airlock/hatch convention, deferred to Phase 13 (launch decision §6).
Off switch
NewJWTValidator needs JWKS_URL; if it is unset the engine logs JWT validator not available (auth disabled) and mounts /api with no auth middleware (rest.go:100-105,140-144). Capped routes then see no claims and meter as Community. Whether production sets JWKS_URL: UNVERIFIED (Railway env not read).
WebSocket
/ws reads ?token= first, then the Authorization header, and validates with the same validator; failure is 401 before upgrade. Frames are not re-validated after the upgrade.
Rate limiter, as written
flowchart LR R["request"] --> K["key = r.RemoteAddr, ip and port"] K --> L["rate.Limiter 10 per s, burst 20, created lazily"] L --> W["limiter.Wait(ctx): blocks until a token is free"] W -- "ctx cancelled or deadline" --> E429["429 Rate limit exceeded"] W -- "token granted" --> H["handler"]Two consequences of the code (rest.go:282-295): Wait delays callers instead of rejecting them, so 429 appears only when the request context ends first; and the key is RemoteAddr, which includes the client port, so a new connection gets a fresh bucket. Behind Railway’s proxy the peer is the proxy. Limiters are never evicted.
Key Terms
- JWKS_URL → the key-set endpoint the engine trusts; unset means auth is off.
- plan_source →
billing,fallbackorno_org; an I-7 marker of how sure web was about the plan. - limiter.Wait → token-bucket wait, not an immediate allow/deny.
Q&A
Q: Who issues the tokens the engine accepts, and which claims does the engine enforce?
A: The web app’s better-auth jwt plugin, Ed25519-signed, 15-minute expiry. The engine enforces signature against JWKS_URL, method EdDSA, issuer (default https://app.nestr.tools), audience (default nestr-engine) and expiry with 30 seconds of leeway.
Q: What happens when JWKS_URL is unset?
A: The validator is not created and the /api subrouter is mounted without auth; the engine only logs a warning. Every capped call then meters as an anonymous Community identity.
Q: Does the rate limiter reject with 429 at 10 requests per second?
A: Not directly. It calls limiter.Wait, which queues the request until a token is available; a 429 is returned only if the request’s context ends while waiting. And because buckets are keyed by IP and port, the limit is per connection rather than per client.
Examples
A script sends 50 requests in a second over one keep-alive connection. About 20 pass at once (burst), the rest are delayed to roughly 10 per second; none get 429 unless the client times out. Opening a new connection per request would give each its own fresh bucket.
neighbors on the map
- Multi-Strategy Authentication in purr-api debugging 401 errors across different clients
- Airlock JWT Handoff & the smo1_session Cookie debugging login loops or session expiry
- Rate Limiting: Edge per-IP, Origin per-Tier debugging 429 errors for specific users
- Airlock Bearer Auth diagnosing a 401 from /v1/ingest/*
- In-Process Rate-Limit Bucket investigating ingest 429s
- Chronicle JWT Claims & Dev Tokens issuing tokens from an external auth provider