Authentication and security
EverDuet delegates identity to WorkOS AuthKit but keeps authorization inside its own domain. That distinction is the center of the security model.
Authentication is not authorization
WorkOS answers:
This valid session belongs to user
user_123.
EverDuet must still answer:
Is
user_123an active member of relationship 42, and may they reveal this response now?
Putting relationship authorization in @couple/core keeps it consistent for Server Actions, route handlers, admin code, and the public API experiment.
Two accepted transports
First-party browser
The Next.js apps use AuthKit's secure cookie session. Unsafe cookie-authenticated API requests also require a same-origin Origin when present, providing CSRF defense in depth.
Public API experiment
The /api/v1/* boundary accepts a WorkOS access token:
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...The verifier checks:
- RS256 signature against WorkOS JWKS;
- exact issuer;
- expiry;
- non-empty subject;
- allowed WorkOS
client_id.
A malformed bearer token never falls back to a valid browser cookie. Silent fallback would turn one broken credential into a different principal and make client bugs difficult to detect.
if (authorizationHeader?.startsWith('Bearer ')) {
return verifyAccessToken(authorizationHeader.slice('Bearer '.length))
}
return authenticateCookieSession()Why keep a public API with no mobile roadmap?
It is an architecture-learning boundary, not a product promise. It teaches:
- contract-first request and response design;
- bearer-token verification;
- generated clients and OpenAPI drift detection;
- domain operations that do not depend on React or Server Actions.
The boundary stays small so it does not force premature mobile features or duplicate every internal operation.
Layered request controls
| Layer | Control | Purpose |
|---|---|---|
| Edge/proxy | TLS, host routing, request limits | Reject obviously invalid traffic early |
| Next.js proxy | 16 KiB mutation-body ceiling | Bound parser and memory work |
| Route | schema, origin, content type | Establish a valid request contract |
| Auth | cookie or verified bearer | Establish one principal |
| Domain | relationship/resource membership | Enforce product permission |
| PostgreSQL | foreign keys, unique indexes, triggers | Preserve truth during races |
No single layer is treated as sufficient.
Privacy changes performance decisions
Prompt answers are intimate content. They are excluded from logs, analytics properties, audit notes, and queue payloads. Structured logs contain safe metadata such as request ID, route, status, duration, and numeric resource IDs.
logger.info('Request completed', {
durationMs,
method: 'POST',
requestId,
route: '/api/v1/prompts/:promptId/response',
statusCode: 200,
})The logger also recursively redacts known secret keys and fields such as answer, body, email, promptText, and response.
Security tradeoffs that remain explicit
Instance-local rate limiting
One web replica uses a bounded in-memory fixed window. It is cheap and effective for accidental abuse, but it is not a distributed guarantee. A second replica is the trigger to move limits to the edge or a shared store.
No broad browser CORS
Installed API clients do not need browser CORS. Opening Access-Control-Allow-Origin without a real browser client would add credential and preflight risk for no user value.
CSP is deferred, not forgotten
Next.js and WorkOS need a nonce-compatible policy tested across login and callback flows. Enabling a brittle policy solely to check a box can break authentication or encourage unsafe wildcard exceptions. The production notes call for report-only validation before enforcement.
Review questions
- Does the operation authorize the resource, or merely authenticate the caller?
- Could a logged error include a token, cookie, answer, or email?
- Does a new cache preserve reveal and relationship boundaries?
- Is a browser-origin capability being enabled for a client that actually exists?
- If this check races, does PostgreSQL still protect the invariant?
Next: performance and caching.