Add an authenticated route
Add a protected API route using Better Auth session identity and owner-scoped data access.
Add an authenticated route
When to use
Use this path when API or web behavior requires a signed-in application user or access to a user-owned resource. It applies to read and write routes backed by Better Auth sessions.
Authentication answers who the caller is. Authorization separately answers whether that user may act on the resource. A successful session must never imply access to every row.
Files and boundaries
apps/server/src/lib/auth.ts: Better Auth configuration and application-user hooks.apps/server/src/infra/http/middlewares/auth.ts: session resolution andrequireUserId.apps/server/src/domains/<domain>/routes.ts: middleware order, validation, and HTTP response.apps/server/src/domains/<domain>/service.ts: use-case and authorization policy coordination.apps/server/src/domains/<domain>/repository.ts: SQL with ownership predicates.packages/contracts/src/: public Zod request schemas and response types.apps/web/src/configs/rpc-client.ts: typed client, cookie transport, stable error conversion.apps/web/src/services/: TanStack Query hooks and cache ownership.apps/server/src/lib/auth.integration.test.ts: real-session and protected-resource reference proof.
Better Auth is the only session authority. The canonical stored application identity is users.id;
domain and wire code may call the same value user_id.
Procedure
-
Define or extend the request schema once in
packages/contracts; export it through the package index. Do not accept an unvalidated body, query, or resource identifier. -
Add repository methods whose signatures require the current
userId. Put the ownership predicate in the same SQL statement that reads or mutates the row:WHERE resource_id = ${resourceId} AND user_id = ${userId}Never fetch by resource ID and perform a later in-memory owner comparison for a write.
-
Let the service translate a missing owned row into the deliberate public policy:
- use
404 NOT_FOUNDfor private resources when confirming existence would disclose another user's data; - use
403 FORBIDDENonly when the resource is already visible and the caller lacks a known action or role.
- use
-
Mount
sessionAuthbefore the protected handlers. In each handler callrequireUserId(c)and pass that value through service and repository layers:export const resourceRoutes = new Hono<AppEnv>() .use(sessionAuth) .get('/:resource_id', async c => { const userId = requireUserId(c) const resource = await service.getResource(userId, c.req.param('resource_id')) return sendSuccess(c, 'Resource fetched', resource) }) -
The foundation has no generic resource-authorization middleware. Keep private-resource ownership in the repository predicate. If a visible resource needs a reusable role or action policy, extract a domain-owned checker only after its resource model and disclosure behavior are defined.
-
Mount the domain router in
apps/server/src/app.ts. Do not put SQL, cookie parsing, or Better Auth internals in the route. -
In the web app, call the route through the generated
apiclient andrpc/rpcPaginated. These preserve Hono inference, include the Better Auth HttpOnly cookie, redirect non-auth 401 responses to login, and throwApiClientErrorwith the stable server code. -
Give every query/mutation a resource-scoped TanStack Query key. Invalidate only affected resources after writes. Do not cache authenticated API responses in the service worker.
-
Add real Postgres HTTP proof for:
- no cookie →
401 UNAUTHORIZED; - expired or revoked session →
401 UNAUTHORIZED; - current owner → expected success contract;
- authenticated wrong owner → deliberate
404or403policy with no protected data; - cross-owner update/delete → no row mutation;
- malformed input →
400before service work.
- no cookie →
-
Run the architecture scan and the full verification path. Inspect the diff for any token, cookie, JWT, owner ID from input, or raw
fetchshortcut.
Verification
Use a disposable Postgres 18 database with the baseline migration applied:
RUN_DB_INTEGRATION_TESTS=1 \
DATABASE_URL='postgres://localhost:5432/app_auth_route_proof?sslmode=disable' \
bun test --cwd apps/server src/lib/auth.integration.test.ts
bun test --cwd packages/create-app src/auth-contract.test.ts
vp check
pnpm test
vp run -r buildThe reference integration creates a real verified Better Auth session, reads an owned notification, rejects a request with no session, hides another user's notification, expires a stored session, and requires the expired cookie to fail.
Security constraints
- The caller identity comes only from
auth.api.getSessionand server context, never request JSON, query, URL owner fields, local storage, or an unverified JWT. - Apply the ownership predicate to the database read/write itself. Test that an unauthorized write changes zero rows.
- Keep Better Auth cookies HttpOnly and sent with
credentials: 'include'; do not copy tokens into application state. - Preserve trusted-origin, secure-cookie, redirect, proxy-header, verification, and rate-limit behavior.
- Prefer non-disclosing
404for private resources. Never include the real owner or protected row in the error. - Log request IDs and stable error codes, not session cookies, tokens, passwords, or private row contents.
Failure modes
- Mounting a handler before
sessionAuth, or using optional auth for a required route. - Accepting
user_idfrom the client and using it instead ofrequireUserId(c). - Reading by resource ID, then authorizing later, especially before a mutation.
- Returning
403or owner metadata in a way that confirms a private resource exists. - Treating a valid session as product entitlement or resource ownership.
- Adding a custom JWT/session refresh path beside Better Auth.
- Using raw browser
fetchwithout credentials or stable error handling. - Redirecting a Better Auth endpoint's own 401 back to login and creating a loop.
- Testing middleware with a fake context while never exercising real session expiry and Postgres.
Rollback and diagnosis
If a route leaks or mutates cross-owner data, disable or unmount that route before attempting a cosmetic client fix. Preserve request IDs, identify the exact repository predicate, and test the affected read and write with two isolated users.
For unexplained 401, inspect cookie presence, exact web/API origins, CORS credentials, trusted
origin configuration, session row expiry/revocation, and proxy header ownership. Do not weaken
cookie or origin policy to make the request pass.
For an unexpected 404/403, verify the session's canonical users.id, active user status,
resource owner/membership row, and the chosen disclosure policy. Do not add a second user key or
fall back to email matching.
Acceptance criteria
- Shared contracts, route validation, service policy, repository ownership, and typed web client agree on one request/response shape.
- No cookie and expired/revoked session return
401. - The owner succeeds; the wrong owner receives the deliberate non-disclosing
404or visible resource403and no protected data. - Cross-owner writes mutate zero rows.
- The client includes credentials, preserves typed errors, and does not service-worker cache the response.
- Real Postgres auth integration, architecture scan,
vp check, tests, and builds pass. - No parallel JWT, cookie, session table, user key, or client-supplied owner trust is introduced.
Agent prohibitions
- Do not add custom JWTs, refresh tokens, session cookies, password logic, or auth tables.
- Do not trust
user_id, owner, role, entitlement, or email supplied by the browser. - Do not authorize after a write or outside the repository predicate.
- Do not expose another user's resource existence, identity, or data in errors or logs.
- Do not bypass the generated Hono client with unauthenticated raw
fetch. - Do not weaken CORS, cookie security, trusted origins, verification, redirects, proxy handling, or rate limits.
- Do not call a mocked middleware test complete without a real expired-session and cross-owner Postgres HTTP test.