Skip to content

Commit f34b394

Browse files
fix(security): Implement JWKS-based JWT verification (#18)
* fix(security): implement JWKS-based JWT verification 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 <noreply@anthropic.com> * fix: handle all JWKS errors in local development 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 40371f3 commit f34b394

2 files changed

Lines changed: 68 additions & 34 deletions

File tree

supabase/functions/import_map.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.45.4",
44
"openai": "npm:openai@4.72.0",
55
"oak": "https://deno.land/x/oak@v12.6.0/mod.ts",
6+
"jose": "https://deno.land/x/jose@v5.2.0/index.ts",
67
"std/path": "https://deno.land/std@0.224.0/path/mod.ts",
78
"std/flags": "https://deno.land/std@0.224.0/flags/mod.ts",
89
"std/dotenv": "https://deno.land/std@0.224.0/dotenv/mod.ts",

supabase/functions/shared/auth.ts

Lines changed: 67 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
11
import { Context } from 'https://deno.land/x/oak@v12.6.0/mod.ts'
2+
import { createRemoteJWKSet, jwtVerify, decodeJwt } from 'jose'
3+
4+
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? ''
5+
6+
// Cache the JWKS client (handles internal caching with 5min TTL)
7+
let _jwks: ReturnType<typeof createRemoteJWKSet> | null = null
8+
9+
function getJwks() {
10+
if (!_jwks && SUPABASE_URL) {
11+
_jwks = createRemoteJWKSet(
12+
new URL(`${SUPABASE_URL}/auth/v1/.well-known/jwks.json`)
13+
)
14+
}
15+
return _jwks
16+
}
217

318
function parseAuthorizationHeader(ctx: Context): string | null {
419
const authHeader = ctx.request.headers.get('authorization') ?? ''
@@ -19,52 +34,70 @@ export async function decodeUserIdFromRequest(ctx: Context): Promise<string> {
1934
throw new Error('Missing authorization header')
2035
}
2136

22-
// In production, Supabase's edge runtime already verifies the JWT before
23-
// our code runs. We decode the payload without re-verifying the signature.
24-
// In local development, tokens come from the local Supabase auth service
25-
// and the database is local, so signature verification adds no security value.
26-
const userId = decodeJwtPayload(token)
27-
if (userId) {
37+
const jwks = getJwks()
38+
if (!jwks) {
39+
throw new Error('SUPABASE_URL not configured')
40+
}
41+
42+
try {
43+
// Try JWKS verification (production uses asymmetric keys)
44+
const { payload } = await jwtVerify(token, jwks, {
45+
issuer: `${SUPABASE_URL}/auth/v1`,
46+
audience: 'authenticated',
47+
})
48+
49+
const userId = payload.sub
50+
if (!userId || typeof userId !== 'string') {
51+
throw new Error('Invalid token: missing sub claim')
52+
}
53+
2854
ctx.state.userId = userId
2955
return userId
56+
57+
} catch (error) {
58+
const errorMessage = error instanceof Error ? error.message : String(error)
59+
60+
// For local development, fall back to decode-only mode for ANY JWKS error.
61+
// Local Supabase uses HS256 (symmetric keys) which aren't exposed via JWKS,
62+
// so JWKS verification will always fail locally (empty keyset, fetch errors, etc.)
63+
if (isLocalDevelopment()) {
64+
return decodeTokenWithoutVerification(token, ctx)
65+
}
66+
67+
throw new Error(`Unauthorized: ${errorMessage}`)
3068
}
69+
}
3170

32-
throw new Error('Unauthorized: Could not extract user ID from token')
71+
function isLocalDevelopment(): boolean {
72+
// In Docker, SUPABASE_URL is set to kong:8000, check for that too
73+
// Also check LOCAL_SUPABASE_URL which explicitly indicates local dev
74+
const localUrl = Deno.env.get('LOCAL_SUPABASE_URL') ?? ''
75+
return SUPABASE_URL.includes('127.0.0.1') ||
76+
SUPABASE_URL.includes('localhost') ||
77+
SUPABASE_URL.includes('kong:') ||
78+
localUrl.includes('127.0.0.1') ||
79+
localUrl.includes('localhost')
3380
}
3481

35-
function decodeJwtPayload(token: string): string | null {
82+
function decodeTokenWithoutVerification(token: string, ctx: Context): string {
3683
try {
37-
const parts = token.split('.')
38-
if (parts.length !== 3) return null
39-
40-
const payloadB64 = parts[1]
41-
const payloadJson = new TextDecoder().decode(base64UrlToUint8Array(payloadB64))
42-
const payload = JSON.parse(payloadJson) as Record<string, unknown>
84+
const payload = decodeJwt(token)
4385

4486
// Check expiration
4587
const now = Math.floor(Date.now() / 1000)
46-
const exp = payload?.exp
47-
if (typeof exp === 'number' && now >= exp) {
48-
return null // Token expired
88+
if (typeof payload.exp === 'number' && now >= payload.exp) {
89+
throw new Error('Token expired')
4990
}
5091

51-
const sub = payload?.sub
52-
return typeof sub === 'string' ? sub : null
53-
} catch {
54-
return null
55-
}
56-
}
92+
const userId = payload.sub
93+
if (!userId || typeof userId !== 'string') {
94+
throw new Error('Invalid token: missing sub claim')
95+
}
5796

58-
function base64UrlToUint8Array(input: string): Uint8Array {
59-
let normalized = input.replace(/-/g, '+').replace(/_/g, '/')
60-
const padding = normalized.length % 4
61-
if (padding) {
62-
normalized += '='.repeat(4 - padding)
63-
}
64-
const binary = atob(normalized)
65-
const bytes = new Uint8Array(binary.length)
66-
for (let i = 0; i < binary.length; i++) {
67-
bytes[i] = binary.charCodeAt(i)
97+
ctx.state.userId = userId
98+
return userId
99+
} catch (error) {
100+
const message = error instanceof Error ? error.message : 'Token decode failed'
101+
throw new Error(`Unauthorized: ${message}`)
68102
}
69-
return bytes
70103
}

0 commit comments

Comments
 (0)