From 47529207d0dc13d1e4c7be7f3691b7de3ca8576b Mon Sep 17 00:00:00 2001 From: justanotheratom Date: Thu, 1 Jan 2026 23:33:57 +0530 Subject: [PATCH 1/2] fix(security): implement JWKS-based JWT verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL SECURITY FIX: JWT signatures were not being verified, allowing attackers to forge tokens and access/delete any user's data. Root cause: Commit 6f41807 removed JWKS verification code assuming gateway verification was enabled, but verify_jwt=false was still set. Changes: - Add jose library for JWT verification - Implement JWKS-based signature verification for production - Validate issuer and audience claims - Fall back to decode-only for local dev (where JWKS is empty) - Remove incompatible deno.lock (version 5 not supported) Security model: - Production: Full JWKS signature verification (ES256/RS256) - Local dev: Decode-only (acceptable since DB is local) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- supabase/functions/import_map.json | 1 + supabase/functions/shared/auth.ts | 105 +++++++++++++++++++---------- 2 files changed, 72 insertions(+), 34 deletions(-) diff --git a/supabase/functions/import_map.json b/supabase/functions/import_map.json index 4cf475c..faf31ec 100644 --- a/supabase/functions/import_map.json +++ b/supabase/functions/import_map.json @@ -3,6 +3,7 @@ "@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.45.4", "openai": "npm:openai@4.72.0", "oak": "https://deno.land/x/oak@v12.6.0/mod.ts", + "jose": "https://deno.land/x/jose@v5.2.0/index.ts", "std/path": "https://deno.land/std@0.224.0/path/mod.ts", "std/flags": "https://deno.land/std@0.224.0/flags/mod.ts", "std/dotenv": "https://deno.land/std@0.224.0/dotenv/mod.ts", diff --git a/supabase/functions/shared/auth.ts b/supabase/functions/shared/auth.ts index c59362d..ffaaea1 100644 --- a/supabase/functions/shared/auth.ts +++ b/supabase/functions/shared/auth.ts @@ -1,4 +1,19 @@ import { Context } from 'https://deno.land/x/oak@v12.6.0/mod.ts' +import { createRemoteJWKSet, jwtVerify, decodeJwt } from 'jose' + +const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? '' + +// Cache the JWKS client (handles internal caching with 5min TTL) +let _jwks: ReturnType | null = null + +function getJwks() { + if (!_jwks && SUPABASE_URL) { + _jwks = createRemoteJWKSet( + new URL(`${SUPABASE_URL}/auth/v1/.well-known/jwks.json`) + ) + } + return _jwks +} function parseAuthorizationHeader(ctx: Context): string | null { const authHeader = ctx.request.headers.get('authorization') ?? '' @@ -19,52 +34,74 @@ export async function decodeUserIdFromRequest(ctx: Context): Promise { throw new Error('Missing authorization header') } - // In production, Supabase's edge runtime already verifies the JWT before - // our code runs. We decode the payload without re-verifying the signature. - // In local development, tokens come from the local Supabase auth service - // and the database is local, so signature verification adds no security value. - const userId = decodeJwtPayload(token) - if (userId) { + const jwks = getJwks() + if (!jwks) { + throw new Error('SUPABASE_URL not configured') + } + + try { + // Try JWKS verification (production uses asymmetric keys) + const { payload } = await jwtVerify(token, jwks, { + issuer: `${SUPABASE_URL}/auth/v1`, + audience: 'authenticated', + }) + + const userId = payload.sub + if (!userId || typeof userId !== 'string') { + throw new Error('Invalid token: missing sub claim') + } + ctx.state.userId = userId return userId + + } catch (error) { + // Check if this is a JWKS error (empty keyset or unsupported alg in local dev) + const errorMessage = error instanceof Error ? error.message : String(error) + const isJwksError = errorMessage.includes('no applicable key found') || + errorMessage.includes('JWKSNoMatchingKey') || + errorMessage.includes('Unsupported') || + errorMessage.includes('empty') + + // For local development (HS256 with empty JWKS), decode without verification + // This is safe because local Supabase uses symmetric keys not exposed via JWKS + if (isJwksError && isLocalDevelopment()) { + return decodeTokenWithoutVerification(token, ctx) + } + + throw new Error(`Unauthorized: ${errorMessage}`) } +} - throw new Error('Unauthorized: Could not extract user ID from token') +function isLocalDevelopment(): boolean { + // In Docker, SUPABASE_URL is set to kong:8000, check for that too + // Also check LOCAL_SUPABASE_URL which explicitly indicates local dev + const localUrl = Deno.env.get('LOCAL_SUPABASE_URL') ?? '' + return SUPABASE_URL.includes('127.0.0.1') || + SUPABASE_URL.includes('localhost') || + SUPABASE_URL.includes('kong:') || + localUrl.includes('127.0.0.1') || + localUrl.includes('localhost') } -function decodeJwtPayload(token: string): string | null { +function decodeTokenWithoutVerification(token: string, ctx: Context): string { try { - const parts = token.split('.') - if (parts.length !== 3) return null - - const payloadB64 = parts[1] - const payloadJson = new TextDecoder().decode(base64UrlToUint8Array(payloadB64)) - const payload = JSON.parse(payloadJson) as Record + const payload = decodeJwt(token) // Check expiration const now = Math.floor(Date.now() / 1000) - const exp = payload?.exp - if (typeof exp === 'number' && now >= exp) { - return null // Token expired + if (typeof payload.exp === 'number' && now >= payload.exp) { + throw new Error('Token expired') } - const sub = payload?.sub - return typeof sub === 'string' ? sub : null - } catch { - return null - } -} + const userId = payload.sub + if (!userId || typeof userId !== 'string') { + throw new Error('Invalid token: missing sub claim') + } -function base64UrlToUint8Array(input: string): Uint8Array { - let normalized = input.replace(/-/g, '+').replace(/_/g, '/') - const padding = normalized.length % 4 - if (padding) { - normalized += '='.repeat(4 - padding) - } - const binary = atob(normalized) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) + ctx.state.userId = userId + return userId + } catch (error) { + const message = error instanceof Error ? error.message : 'Token decode failed' + throw new Error(`Unauthorized: ${message}`) } - return bytes } From 0df51df58938d19202a2b9e0ca96a4cc3b830e78 Mon Sep 17 00:00:00 2001 From: justanotheratom Date: Fri, 2 Jan 2026 10:48:31 +0530 Subject: [PATCH 2/2] fix: handle all JWKS errors in local development MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fall back to decode-only mode for ANY JWKS verification error in local development, not just specific error messages. This handles network errors, fetch failures, and other edge cases that wouldn't match the previous substring checks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- supabase/functions/shared/auth.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/supabase/functions/shared/auth.ts b/supabase/functions/shared/auth.ts index ffaaea1..5793412 100644 --- a/supabase/functions/shared/auth.ts +++ b/supabase/functions/shared/auth.ts @@ -55,16 +55,12 @@ export async function decodeUserIdFromRequest(ctx: Context): Promise { return userId } catch (error) { - // Check if this is a JWKS error (empty keyset or unsupported alg in local dev) const errorMessage = error instanceof Error ? error.message : String(error) - const isJwksError = errorMessage.includes('no applicable key found') || - errorMessage.includes('JWKSNoMatchingKey') || - errorMessage.includes('Unsupported') || - errorMessage.includes('empty') - - // For local development (HS256 with empty JWKS), decode without verification - // This is safe because local Supabase uses symmetric keys not exposed via JWKS - if (isJwksError && isLocalDevelopment()) { + + // For local development, fall back to decode-only mode for ANY JWKS error. + // Local Supabase uses HS256 (symmetric keys) which aren't exposed via JWKS, + // so JWKS verification will always fail locally (empty keyset, fetch errors, etc.) + if (isLocalDevelopment()) { return decodeTokenWithoutVerification(token, ctx) }