ADR-064: Authorize Mutations from Authenticated Identity, Not Client-Supplied IDs
ADR-064: Authorize Mutations from Authenticated Identity, Not Client-Supplied IDs
Status: Implemented Date: 2026-06-02 Deciders: Engineering
Context
A mutation's authorization decision must answer "is this actor allowed to do
this?" The actor is whoever the verified JWT says it is — req.user.userId,
populated by authMiddleware after signature + expiry validation. A user_id
field arriving in a request body or query string is, by contrast, attacker-
controlled: it is whatever the client chose to send.
Karmyq drifted into trusting the latter in several places:
-
Match-action endpoints (the bug that prompted this ADR). The request-service handlers
PUT /matches/:id/accept,/reject, and/completesit behindauthMiddleware, soreq.user.userIdwas always available — yet they authorized againstreq.body.user_id:Handler Guard (before) Risk acceptresponder_id !== body.user_id(admin-proposed) /requester_id !== body.user_idForge an accept as another participant rejectrequester_id !== body.user_id && responder_id !== body.user_idForge a reject/withdraw on someone else's match completerequester_id !== body.user_id && responder_id !== body.user_idForge a completion confirmation Any logged-in user could act on another user's match by supplying a participant's id in the body — a broken-access-control / IDOR flaw.
completeis the most damaging: a forged completion confirmation publishes thematch_completedevent, which awards karma. -
SSE stream authentication (Sprint 81, the prior instance). The notification stream once trusted a
userIdpath/query value; Sprint 81 closed that by verifying the JWT (passed asaccess_tokenbecause browserEventSourcecannot set headers) and rejecting any request whose token identity does not match. Same principle, applied to a read stream.
These are the same class of mistake: authorizing from input we received rather than from identity we verified.
Decision
Mutation authorization reads the actor's identity from the authenticated JWT
(req.user.userId), never from a client-supplied id.
Concretely, for Sprint 83:
accept/reject/completeare typedAuthenticatedRequestand derive the actinguser_idfromreq.user!.userId. The guard comparison logic is unchanged — only the source of the identity moved from body to token. A leftoverbody.user_idfrom an un-deployed client is tolerated (ignored), not trusted. Legitimate body input that is not identity (e.g.accept'stravel_time_minutes) still comes from the body.- The frontend (
api.tsacceptMatch/rejectMatch/completeMatch,CommitmentsTab.tsx,MyRequestsTab.tsx) stops sendinguser_identirely. - A regression test (
request-service/tests/regression/sprint-83-match-action-auth.test.ts) locks the contract: a forgedbody.user_idnever grants access, and a missingbody.user_idnever blocks a legitimate participant.
SSE JWT-in-URL log hardening (carry-forward)
Because the SSE token rides in the URL as access_token, it would land in nginx
access logs in cleartext. An http-scope map rewrites the access_token value to
*** in a sanitized request line, and the /api/notifications location logs with
a log_format that uses the sanitized line. This removes the cleartext-in-logs
exposure without changing the auth flow.
Token TTL decision
Access tokens are retained at 1 hour with rotation. This balances stream/UX continuity against blast radius. Shortening the TTL is not adopted here: a shorter TTL degrades long-lived SSE streams unless paired with a refresh-on-SSE story, which is out of scope. Documented as a deliberate decision, not an oversight.
Consequences
Positive
- The match-action IDOR is closed; karma can no longer be awarded via a forged completion.
- A single, stated rule ("authorize from verified identity") now covers both the SSE stream (Sprint 81) and match mutations (Sprint 83), and is the standing expectation for every future mutation.
- The SSE token no longer appears in access logs.
Negative / trade-offs
- Clients must be authenticated for these actions (already true behind
authMiddleware); there is no "act on behalf of" path via body id (intended). - The 1h token TTL remains a known, accepted exposure window for a leaked token.
Follow-ups
DELETE /matches/:id(cancel) readsbody.user_idwith the same pattern and should be migrated toreq.user.userIdin a future pass; it was outside this sprint's confirmed scope. Until then it carries the same IDOR class as the three endpoints fixed here.- Audit remaining services for any mutation still authorizing from a body/query id.