Skip to content

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_123 an 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:

http
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

The verifier checks:

  1. RS256 signature against WorkOS JWKS;
  2. exact issuer;
  3. expiry;
  4. non-empty subject;
  5. 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.

ts
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

LayerControlPurpose
Edge/proxyTLS, host routing, request limitsReject obviously invalid traffic early
Next.js proxy16 KiB mutation-body ceilingBound parser and memory work
Routeschema, origin, content typeEstablish a valid request contract
Authcookie or verified bearerEstablish one principal
Domainrelationship/resource membershipEnforce product permission
PostgreSQLforeign keys, unique indexes, triggersPreserve 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.

ts
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.

Built as a living architecture notebook for a personal project.