From af55cdd8a6b30674028d7fc6dc4b760804ebd517 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sun, 22 Mar 2026 09:38:52 +0100 Subject: [PATCH] fix: add HS256 fallback for JWT verification in local dev Local GoTrue signs JWTs with HS256, but the JWKS endpoint returns an empty key set since there are no RSA keys. Fall back to HS256 shared secret verification when JWKS fails, using SUPABASE_JWT_SECRET. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...ification-failing-in-local-dev-hs256-fa.md | 10 ++++++ ...m-section-a-floating-sidebar-on-desktop.md | 4 --- .env.example | 2 ++ backend/.env.example | 2 ++ backend/src/app/core/auth.py | 36 ++++++++++++------- backend/src/app/core/config.py | 1 + docker-compose.yml | 3 +- 7 files changed, 40 insertions(+), 18 deletions(-) create mode 100644 .beans/nuzlocke-tracker-eg7j--fix-jwt-verification-failing-in-local-dev-hs256-fa.md diff --git a/.beans/nuzlocke-tracker-eg7j--fix-jwt-verification-failing-in-local-dev-hs256-fa.md b/.beans/nuzlocke-tracker-eg7j--fix-jwt-verification-failing-in-local-dev-hs256-fa.md new file mode 100644 index 0000000..e29a341 --- /dev/null +++ b/.beans/nuzlocke-tracker-eg7j--fix-jwt-verification-failing-in-local-dev-hs256-fa.md @@ -0,0 +1,10 @@ +--- +# nuzlocke-tracker-eg7j +title: Fix JWT verification failing in local dev (HS256 fallback) +status: in-progress +type: bug +created_at: 2026-03-22T08:37:18Z +updated_at: 2026-03-22T08:37:18Z +--- + +Local GoTrue signs JWTs with HS256, but the JWKS migration only supports RS256. The JWKS endpoint returns empty keys locally, causing 500 errors on all authenticated endpoints. Add HS256 fallback using SUPABASE_JWT_SECRET for local dev. diff --git a/.beans/nuzlocke-tracker-lkro--ux-make-team-section-a-floating-sidebar-on-desktop.md b/.beans/nuzlocke-tracker-lkro--ux-make-team-section-a-floating-sidebar-on-desktop.md index 10c8439..d1ede42 100644 --- a/.beans/nuzlocke-tracker-lkro--ux-make-team-section-a-floating-sidebar-on-desktop.md +++ b/.beans/nuzlocke-tracker-lkro--ux-make-team-section-a-floating-sidebar-on-desktop.md @@ -5,11 +5,7 @@ status: todo type: feature priority: normal created_at: 2026-03-21T21:50:48Z -<<<<<<< Updated upstream -updated_at: 2026-03-21T22:04:08Z -======= updated_at: 2026-03-22T08:08:13Z ->>>>>>> Stashed changes --- ## Problem diff --git a/.env.example b/.env.example index aba12cf..5e15c1c 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/nuzlocke # Supabase Auth (backend uses JWKS from this URL for JWT verification) # For local dev with GoTrue container: SUPABASE_URL=http://localhost:9999 +# HS256 fallback for local GoTrue (not needed for Supabase Cloud): +SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc0MDQwNjEzLCJleHAiOjIwODk0MDA2MTN9.EV6tRj7gLqoiT-l2vDFw_67myqRjwpcZTuRb3Xs1nr4 # For production, replace with your Supabase cloud values: # SUPABASE_URL=https://your-project.supabase.co diff --git a/backend/.env.example b/backend/.env.example index 9b444f2..fbe78f0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,3 +11,5 @@ DATABASE_URL="sqlite:///./nuzlocke.db" # Supabase Auth (JWKS used for JWT verification) SUPABASE_URL=https://your-project.supabase.co SUPABASE_ANON_KEY=your-anon-key +# HS256 fallback for local GoTrue (not needed for Supabase Cloud): +# SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long diff --git a/backend/src/app/core/auth.py b/backend/src/app/core/auth.py index aa6172a..d717aca 100644 --- a/backend/src/app/core/auth.py +++ b/backend/src/app/core/auth.py @@ -44,26 +44,36 @@ def _extract_token(request: Request) -> str | None: return parts[1] -def _verify_jwt(token: str) -> dict | None: - """Verify JWT using JWKS public key. Returns payload or None.""" - client = _get_jwks_client() - if not client: +def _verify_jwt_hs256(token: str) -> dict | None: + """Verify JWT using HS256 shared secret. Returns payload or None.""" + if not settings.supabase_jwt_secret: return None try: - signing_key = client.get_signing_key_from_jwt(token) - payload = jwt.decode( + return jwt.decode( token, - signing_key.key, - algorithms=["RS256"], + settings.supabase_jwt_secret, + algorithms=["HS256"], audience="authenticated", ) - return payload - except jwt.ExpiredSignatureError: - return None except jwt.InvalidTokenError: return None - except PyJWKClientError: - return None + + +def _verify_jwt(token: str) -> dict | None: + """Verify JWT using JWKS (RS256), falling back to HS256 shared secret.""" + client = _get_jwks_client() + if client: + try: + signing_key = client.get_signing_key_from_jwt(token) + return jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + audience="authenticated", + ) + except jwt.InvalidTokenError, PyJWKClientError: + pass + return _verify_jwt_hs256(token) def get_current_user(request: Request) -> AuthUser | None: diff --git a/backend/src/app/core/config.py b/backend/src/app/core/config.py index 84541c3..7ef08af 100644 --- a/backend/src/app/core/config.py +++ b/backend/src/app/core/config.py @@ -20,6 +20,7 @@ class Settings(BaseSettings): # Supabase Auth supabase_url: str | None = None supabase_anon_key: str | None = None + supabase_jwt_secret: str | None = None settings = Settings() diff --git a/docker-compose.yml b/docker-compose.yml index dae4909..09d43ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,9 @@ services: environment: - DEBUG=true - DATABASE_URL=postgresql://postgres:postgres@db:5432/nuzlocke - # Auth - uses JWKS from GoTrue for JWT verification + # Auth - uses JWKS from GoTrue for JWT verification, with HS256 fallback - SUPABASE_URL=http://gotrue:9999 + - SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long depends_on: db: condition: service_healthy