A revoked role stays usable for up to 15 minutes
Status: open finding, not yet fixed. Filed 22 September 2026. Severity: low — bounded, requires an already-trusted account, no data exposure beyond the grievance queue. Worth fixing; not worth blocking a release for. Raised by: QA, while root-causing TC-DFG-016 (sub-admin grant returned 200 but the next request still 403'd). The grant direction they hit is correct behaviour. Investigating it surfaced the revoke direction, which is not. Area: authenticate / authorize / adminService.setSubAdminRole.
1. The finding in one line
role is a claim baked into the access token, and nothing invalidates that token when an admin changes the role — so revoking sub-admin leaves the revoked user able to adjudicate grievances until their access token expires, up to 15 minutes later.
2. Why granting is fine and revoking is not
Both directions have the same lag. Only one of them is a problem:
| Direction | What the lag does | Verdict |
|---|---|---|
| Grant | The new sub-admin gets 403 until their token rotates. They wait, or refresh. | Correct. A capability arriving late is an inconvenience. |
| Revoke | The ex-sub-admin keeps authorize('admin', 'sub-admin') for up to 15 min. | The finding. A capability leaving late is a privilege-retention window. |
Security properties are meant to fail closed. This one fails open in exactly the direction where failing open costs something.
3. Mechanism
The chain is four steps, and the role never leaves the token:
signAccessTokenwritesroleinto the JWT payload at login and at every refresh.authenticatesetsreq.user.activeRole = payload.role— from the token. It does hit the database on every request, but only to validate the session (device row,refreshToken,deletedAt,kycStatus). It never reads the role.authorize()gates purely onreq.user.activeRole.setSubAdminRoleupdatesUser.rolesandUser.activeRoleand stops there. It does not revoke the device session, and there is no token version or epoch to bump.
The lag self-heals because refreshTokens re-reads the user row and signs the next access token with user.activeRole. So the window is bounded by the access-token TTL — JWT_ACCESS_EXPIRES_IN, currently 15m, confirmed as 900s on tokens captured from QA on 21 September.
4. Blast radius
Everything behind authorize('admin', 'sub-admin') in grievance.routes.ts:
GET /api/v1/grievances/queue— read the full ops queuePOST /api/v1/grievances/:id/assignPOST /api/v1/grievances/:id/reviewPOST /api/v1/grievances/:id/request-infoPOST /api/v1/grievances/:id/resolve— write an adjudication outcome
resolve is the one that matters: it can close a grievance with a fault outcome, which feeds the counterparty's trust penalty. A revoked adjudicator could land a verdict after losing the authority to make it.
Note the asymmetry with the rest of the middleware: a user who is deleted or deactivated loses access on the very next request, because authenticate checks those against the database. A user who is demoted does not. Same middleware, same request, same already-fetched row.
5. There is precedent for treating this as worth fixing
authenticate already carries a long comment explaining why logout and account deletion must invalidate the access token and not merely the refresh token — "a session must be provable, not merely un-disprovable" — with STALE_SESSION, SESSION_REVOKED, ACCOUNT_DELETED and ACCOUNT_DEACTIVATED added for exactly that reason. A revoked role is the same argument applied to a capability instead of an identity. The machinery to close it already exists.
6. Options
| # | Fix | Cost | Trade-off |
|---|---|---|---|
| 1 | Read the role from the database in authenticate. The device lookup already joins user and already selects deletedAt + kycStatus; adding activeRole costs no extra query. Fall back to the token claim in the existing catch, so a DB blip still fails open exactly as it does today. | Small | The JWT role claim becomes advisory. Grant and revoke take effect on the next request. Verified safe: there is no role-switch endpoint, so activeRole only changes at profile completion and at admin grant. |
| 2 | Revoke the session on role change. setSubAdminRole nulls the target's Device.refreshToken; the existing SESSION_REVOKED branch then rejects the next request. | Smallest | Logs the user out of every device. For a deliberate ops-account role change that is arguably the correct outcome, but it is a blunt instrument if the role ever applies to ordinary users. |
| 3 | Shorten the access TTL for admin/sub-admin sessions. | Small | Narrows the window without closing it, and adds a second TTL policy to reason about. Weakest of the three. |
| 4 | Accept and document the 15-minute window. | None | Honest, but leaves resolve reachable post-revocation. |
Recommendation: option 1. It is the only one that closes both directions, it removes the divergence class rather than narrowing it, and because the user row is already being fetched it adds no per-request cost. Option 2 is a reasonable smaller step if the team prefers to keep the token claim authoritative.
Whichever is chosen, setSubAdminRole should get a test asserting that the revoked user cannot reach GET /grievances/queue on the next request — the assertion that would have caught this.
7. Reproducing it
- Grant sub-admin to a test user. Have them log in; keep the access token.
- Confirm
GET /api/v1/grievances/queuereturns 200. - Revoke sub-admin:
POST /api/v1/admin/users/:userId/sub-adminwith{ grant: false }. - Immediately re-call
GET /api/v1/grievances/queuewith the same token.
Observed: 200. Expected after a fix: 403.
Decoding the token between steps 3 and 4 shows the stale "role":"sub-admin" claim, which is the whole finding in one line of base64.
8. What QA should do in the meantime
Nothing that masks it. Keep re-authenticating (or better, calling POST /api/v1/auth/refresh) after a role change — that is the current contract and the test should encode it. When this is fixed, the refresh step becomes unnecessary rather than wrong, so nothing has to be rewritten.
