fix(auth): PLT-1318 verify Supabase JWT signature before trusting accountId - #176
Conversation
…ountId The Supabase-JWT auth path decoded the token payload client-side (decodeJwtPayload) and trusted app_metadata.accountId with no signature or expiry check. A forged, unsigned token carrying an arbitrary accountId therefore resolved to that account's Browserless API key — a full authentication bypass / account takeover. resolveApiKey now verifies the access token by presenting it to Supabase Auth's /auth/v1/user endpoint (hosted Supabase in prod, the local Supabase stack in dev — same REST surface), which validates the signature, expiry, and revocation server-side. The accountId is read only from that verified response, never from the client-decoded payload. The resolver cache is keyed on the token hash so only verified tokens can populate it. Removed decodeJwtPayload from utils (the decode-without-verify footgun) and its now-unused type import. Tests: rewrote account-resolver.spec to model the verify-then-lookup flow and added an explicit forged/rejected-token test asserting no account lookup and no key leak. Full suite 469 passing, lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Walkthrough
ChangesAccount resolution
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant Resolver
participant SupabaseAuth
participant PostgREST
Client->>Resolver: Provide access token
Resolver->>SupabaseAuth: Verify token at /auth/v1/user
SupabaseAuth-->>Resolver: Return verified accountId metadata
Resolver->>PostgREST: Query account by accountId
PostgREST-->>Resolver: Return api_key and email
Resolver-->>Client: Return resolved account details
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/lib/account-resolver.ts (1)
2-3: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
SupabaseJwtPayloadis misapplied to the verified Auth API response, not a JWT payload.
SupabaseJwtPayload(persrc/@types/types.d.ts) models a decoded JWT payload, but here it types the JSON body returned by/auth/v1/user— a different shape (e.g. the real field isid, notsub). Happens to work because onlyapp_metadata.accountIdis read, but it's misleading given the doc comment's explicit warning against trusting decoded JWT payloads — a future reader could mistake this typed value for an unverified decode.♻️ Suggested fix: introduce a dedicated response type
-import type { SupabaseJwtPayload } from '../@types/types.js'; +import type { SupabaseAuthUser } from '../@types/types.js';- const user = (await response.json()) as SupabaseJwtPayload; + const user = (await response.json()) as SupabaseAuthUser;Also applies to: 49-58
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/account-resolver.ts` around lines 2 - 3, Replace the SupabaseJwtPayload annotation used for the verified Auth API user response in the account-resolution flow with a dedicated type representing the `/auth/v1/user` response, including its actual `id` and nested `app_metadata` shape. Define or reuse this response type near the resolver and update the related response variable and `app_metadata.accountId` access without changing the existing resolution behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/lib/account-resolver.ts`:
- Around line 34-40: Update the Supabase Auth verification fetch in
resolveApiKey to use an AbortController-based timeout, aborting the request
after the configured timeout and preserving the existing request headers and
response handling. Ensure the timeout is cleaned up when the fetch completes so
resources are not retained.
- Around line 14-22: The account resolver’s token cache must not bypass Supabase
validation or rely on a truncated hash key. Update the cache-hit path in the
account resolution logic to revalidate the access token with Supabase Auth, and
use the full SHA-256 token fingerprint for cache keys; preserve returning the
verified account/API key only after successful validation.
---
Nitpick comments:
In `@src/lib/account-resolver.ts`:
- Around line 2-3: Replace the SupabaseJwtPayload annotation used for the
verified Auth API user response in the account-resolution flow with a dedicated
type representing the `/auth/v1/user` response, including its actual `id` and
nested `app_metadata` shape. Define or reuse this response type near the
resolver and update the related response variable and `app_metadata.accountId`
access without changing the existing resolution behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 5e562507-d961-4541-bfc8-ebdafdf40660
📒 Files selected for processing (3)
src/lib/account-resolver.tssrc/lib/utils.tstest/lib/account-resolver.spec.ts
💤 Files with no reviewable changes (1)
- src/lib/utils.ts
Two review findings on the verification path:
1. Cache could bypass verification: caching the resolved account by a
truncated token hash meant a revoked/expired token kept resolving for
up to the cache TTL, and the 64-bit key had a (tiny) collision
surface. Now the token is verified with Supabase on EVERY call and
only the stable accountId -> {apiKey,email} PostgREST lookup is
cached, keyed by the verified account UUID (no token material in the
key).
2. No timeout on the Supabase calls: a slow/unresponsive Supabase would
hang resolveApiKey (and the request holding it) forever. Both the
/auth/v1/user verification and the PostgREST lookup now go through a
fetchWithTimeout helper bounded by an AbortController (5s).
Tests: cache test rewritten to assert verify-every-call + lookup-cached;
added a test asserting both fetches carry an AbortSignal. Suite 470
passing, lint clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/lib/account-resolver.ts (1)
12-29: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winTimeout guard now in place — resolves prior major issue.
fetchWithTimeoutcorrectly bounds every Supabase call with anAbortControllerand clears the timer infinally, addressing the previously flagged unboundedfetchto/auth/v1/user.One optional improvement: an aborted call surfaces as a raw
AbortErrorto callers, indistinguishable from other network failures. Consider mapping it to an explicit timeout error for clearer diagnostics.♻️ Optional: surface a clear timeout error
async function fetchWithTimeout( url: string, init: RequestInit, ): Promise<Response> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), SUPABASE_TIMEOUT_MS); try { - return await fetch(url, { ...init, signal: controller.signal }); + return await fetch(url, { ...init, signal: controller.signal }); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + throw new Error(`Request to ${url} timed out after ${SUPABASE_TIMEOUT_MS}ms`); + } + throw err; } finally { clearTimeout(timer); } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/account-resolver.ts` around lines 12 - 29, Optionally update fetchWithTimeout to detect when its AbortController timeout aborts the fetch and rethrow a clear timeout-specific error, while preserving existing error propagation for non-timeout failures and timer cleanup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/lib/account-resolver.ts`:
- Around line 12-29: Optionally update fetchWithTimeout to detect when its
AbortController timeout aborts the fetch and rethrow a clear timeout-specific
error, while preserving existing error propagation for non-timeout failures and
timer cleanup.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 5b6c13aa-fd69-4c1b-9f7e-73c39e2220ae
📒 Files selected for processing (2)
src/lib/account-resolver.tstest/lib/account-resolver.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- test/lib/account-resolver.spec.ts
Instead of verifying with Supabase on every request, cache the verified
accountId keyed by a FULL SHA-256 of the token (verifyCache, 5-min TTL),
alongside the existing accountId -> {apiKey,email} lookup cache. Within
the TTL a repeat token skips both Supabase calls; after it, the next
request re-verifies.
Deliberate tradeoff documented in code: a revoked/expired token keeps
resolving for up to the TTL (5 min) before re-verification. Access
tokens are short-lived, so this window is acceptable; shorten the TTL if
faster revocation propagation is needed. Full-length hash (not the
truncated hashToken) removes the collision surface CodeRabbit flagged.
Failed verifications throw before caching, so a forged token can't
poison the cache. clearResolverCache() now clears both caches.
Test updated: 2nd call within TTL hits neither Supabase endpoint.
Suite 470 passing, lint clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🤖 I have created a release *beep* *boop* --- ## [1.12.0](v1.11.0...v1.12.0) (2026-07-15) ### Features * add compliant MCP tool surface via MCP_COMPLIANCE_MODE ([#166](#166)) ([0109fbb](0109fbb)) ### Bug Fixes * **auth:** PLT-1318 verify Supabase JWT signature before trusting accountId ([#176](#176)) ([4778052](4778052)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: browserless-actions-bot[bot] <186328842+browserless-actions-bot[bot]@users.noreply.github.com>
Summary
Fixes PLT-1318 — a Supabase-JWT authentication bypass in the MCP server.
The JWT auth path (
resolveApiKey→decodeJwtPayload) decoded the token payload client-side and trusted it without verifying the signature or expiry. A forged, unsigned token carrying an attacker-chosenapp_metadata.accountIdresolved straight to that account's Browserless API key via PostgREST — a full account takeover with no credentials.Reproduction (before fix)
A 3-segment token with a real-looking header, an attacker-chosen
accountId, and a garbage signature returned the victim'sblsk_key. No signature check existed anywhere in the path.Fix
src/lib/account-resolver.ts:resolveApiKeynow verifies the access token against Supabase Auth (GET ${SUPABASE_URL}/auth/v1/user) — hosted Supabase in prod, the local Supabase stack in dev; same REST surface. Supabase validates the signature, expiry, and revocation server-side.accountIdis read only from the verified response, never from the client-decoded payload.expgap: expired tokens are now rejected.src/lib/utils.ts:decodeJwtPayload(the decode-without-verify footgun) and its now-unused type import.Verification
test/lib/account-resolver.spec.tsrewritten to model the verify-then-lookup flow, with a new explicit forged/rejected-token test asserting no account lookup and no key leak.npm run lintclean,npm run buildclean.Notes for reviewers
🤖 Generated with Claude Code
Summary by CodeRabbit
Security Improvements
Bug Fixes
Tests