Skip to content

Commit 0011fff

Browse files
authored
Merge pull request #264 from DevanshuNEU/fix/auth-jwt-fallback
fix: auth resilience -- graceful JWT fallback + placeholder detection (production outage postmortem)
2 parents d6563d1 + 89e0553 commit 0011fff

3 files changed

Lines changed: 177 additions & 13 deletions

File tree

backend/config/startup_checks.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,15 @@ def validate_environment() -> None:
5353
if not os.getenv(var_name):
5454
logger.warning(f"{var_name} not set ({description}). {fallback_msg}")
5555

56+
# Validate JWT secret looks real (not a placeholder)
57+
jwt_secret = os.getenv("SUPABASE_JWT_SECRET", "")
58+
if jwt_secret and len(jwt_secret) < 32:
59+
logger.error(
60+
"SUPABASE_JWT_SECRET is too short -- likely a placeholder. "
61+
"Real Supabase JWT secrets are 40+ characters. "
62+
"Auth will fall back to slow API verification until this is fixed. "
63+
"Get the real secret: Supabase dashboard -> Settings -> API -> JWT Secret",
64+
secret_length=len(jwt_secret),
65+
)
66+
5667
logger.info("Environment validation passed")

backend/services/auth.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ def __init__(self):
3030

3131
if not self.jwt_secret:
3232
logger.warning("SUPABASE_JWT_SECRET not set -- falling back to API-based verification")
33+
elif len(self.jwt_secret) < 32 or self.jwt_secret in (
34+
"dev-secret-key", "secret", "your-jwt-secret", "change-me",
35+
"test-secret-key", "super-secret-jwt-token-with-at-least-32-characters",
36+
):
37+
logger.error(
38+
"SUPABASE_JWT_SECRET looks like a placeholder. "
39+
"Local JWT verification will fail for real Supabase tokens. "
40+
"Get the real secret from Supabase dashboard -> Settings -> API -> JWT Secret. "
41+
"Falling back to API-based verification until fixed.",
42+
secret_length=len(self.jwt_secret),
43+
)
44+
# Null out the secret so verify_jwt takes the API path directly
45+
self.jwt_secret = None
3346

3447
self.client: Client = create_client(self.supabase_url, self.supabase_key)
3548

@@ -38,16 +51,33 @@ def verify_jwt(self, token: str) -> Dict[str, Any]:
3851
Verify Supabase JWT token locally using the signing secret.
3952
4053
No network call required -- instant verification using HS256.
41-
Falls back to Supabase API call if JWT_SECRET is not configured.
54+
Falls back to Supabase API call if JWT_SECRET is not configured,
55+
or if local decode fails (wrong secret, config mismatch).
4256
"""
4357
if token.lower().startswith("bearer "):
4458
token = token[7:]
4559

46-
# local decode when secret is available (fast path, no network)
60+
# Local decode when secret is available (fast path, no network)
4761
if self.jwt_secret:
48-
return self._verify_local(token)
62+
try:
63+
return self._verify_local(token)
64+
except TokenExpiredError:
65+
# Expired is expired -- no point retrying via API
66+
raise
67+
except TokenMissingClaimError:
68+
# Token decoded but missing required claims -- won't help to retry
69+
raise
70+
except InvalidTokenError:
71+
# Could be wrong secret, config mismatch, or genuinely bad token.
72+
# Try API verification before giving up -- a wrong JWT_SECRET
73+
# should degrade to slow auth, not broken auth.
74+
logger.warning(
75+
"Local JWT decode failed, falling back to API verification. "
76+
"Check SUPABASE_JWT_SECRET if this persists."
77+
)
78+
return self._verify_via_api(token)
4979

50-
# fallback: API call to Supabase (slow path, requires network)
80+
# No secret configured -- API call to Supabase (slow path)
5181
return self._verify_via_api(token)
5282

5383
def _verify_local(self, token: str) -> Dict[str, Any]:

backend/tests/test_jwt_local_decode.py

Lines changed: 132 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from unittest.mock import patch, MagicMock
66

77

8-
JWT_SECRET = "test-jwt-secret-for-unit-tests"
8+
JWT_SECRET = "test-jwt-secret-for-unit-tests-must-be-32-chars-or-longer"
99

1010

1111
def _make_token(payload: dict, secret: str = JWT_SECRET) -> str:
@@ -57,12 +57,22 @@ def test_expired_token_raises_error(self, auth_service):
5757
with pytest.raises(TokenExpiredError):
5858
auth_service.verify_jwt(token)
5959

60-
def test_wrong_secret_raises_error(self, auth_service):
60+
def test_wrong_secret_falls_back_not_raises(self, auth_service):
61+
"""Wrong secret should trigger API fallback, not raise."""
6162
token = _make_token({"sub": "user-000"}, secret="wrong-secret")
6263

63-
from services.exceptions import InvalidTokenError
64-
with pytest.raises(InvalidTokenError):
65-
auth_service.verify_jwt(token)
64+
# Set up API fallback mock
65+
user = MagicMock()
66+
user.id = "user-000"
67+
user.email = "fallback@test.com"
68+
user.user_metadata = {}
69+
response = MagicMock()
70+
response.user = user
71+
auth_service.client.auth.get_user.return_value = response
72+
73+
result = auth_service.verify_jwt(token)
74+
assert result["user_id"] == "user-000"
75+
auth_service.client.auth.get_user.assert_called_once()
6676

6777
def test_missing_sub_claim_raises_error(self, auth_service):
6878
token = _make_token({"email": "no-sub@test.com"})
@@ -71,13 +81,23 @@ def test_missing_sub_claim_raises_error(self, auth_service):
7181
with pytest.raises(TokenMissingClaimError):
7282
auth_service.verify_jwt(token)
7383

74-
def test_wrong_audience_raises_error(self, auth_service):
84+
def test_wrong_audience_falls_back_not_raises(self, auth_service):
85+
"""Wrong audience should trigger API fallback, not raise."""
7586
payload = {"sub": "user-aud", "aud": "wrong-audience", "exp": int(time.time()) + 3600}
7687
token = pyjwt.encode(payload, JWT_SECRET, algorithm="HS256")
7788

78-
from services.exceptions import InvalidTokenError
79-
with pytest.raises(InvalidTokenError):
80-
auth_service.verify_jwt(token)
89+
# Set up API fallback mock
90+
user = MagicMock()
91+
user.id = "user-aud"
92+
user.email = "aud@test.com"
93+
user.user_metadata = {}
94+
response = MagicMock()
95+
response.user = user
96+
auth_service.client.auth.get_user.return_value = response
97+
98+
result = auth_service.verify_jwt(token)
99+
assert result["user_id"] == "user-aud"
100+
auth_service.client.auth.get_user.assert_called_once()
81101

82102
def test_no_network_call_made(self, auth_service):
83103
"""The whole point of OPE-75: verify_jwt should NOT hit the network."""
@@ -134,3 +154,106 @@ def test_falls_back_to_api_when_no_secret(self):
134154

135155
assert result["user_id"] == "api-user-123"
136156
client.auth.get_user.assert_called_once_with("some-token")
157+
158+
def test_wrong_secret_falls_back_to_api(self):
159+
"""
160+
POSTMORTEM TEST -- Feb 2026 production auth outage.
161+
162+
Scenario: SUPABASE_JWT_SECRET is set but WRONG (doesn't match
163+
what Supabase uses to sign tokens). Local decode fails.
164+
System MUST fall back to API verification instead of returning 401.
165+
166+
Before this fix, wrong secret = broken auth for all users.
167+
After this fix, wrong secret = slow auth (API call) but working.
168+
"""
169+
with patch("services.auth.create_client") as mock_client:
170+
client = MagicMock()
171+
user = MagicMock()
172+
user.id = "fallback-user-456"
173+
user.email = "fallback@test.com"
174+
user.user_metadata = {"tier": "pro"}
175+
response = MagicMock()
176+
response.user = user
177+
client.auth.get_user.return_value = response
178+
mock_client.return_value = client
179+
180+
with patch.dict("os.environ", {
181+
"SUPABASE_URL": "https://test.supabase.co",
182+
"SUPABASE_ANON_KEY": "test-key",
183+
# Real-length secret but WRONG -- simulates production mismatch
184+
"SUPABASE_JWT_SECRET": "a" * 64,
185+
}):
186+
from services.auth import SupabaseAuthService
187+
service = SupabaseAuthService()
188+
189+
# Token signed with a DIFFERENT secret (like Supabase would)
190+
token = _make_token(
191+
{"sub": "fallback-user-456", "email": "fallback@test.com"},
192+
secret="the-real-supabase-secret-that-we-dont-have",
193+
)
194+
195+
result = service.verify_jwt(token)
196+
197+
# Should succeed via API fallback, not 401
198+
assert result["user_id"] == "fallback-user-456"
199+
assert result["email"] == "fallback@test.com"
200+
# Verify it actually used the API path
201+
client.auth.get_user.assert_called_once()
202+
203+
def test_expired_token_does_not_fallback(self):
204+
"""Expired tokens should NOT try API fallback -- expired is expired."""
205+
with patch("services.auth.create_client") as mock_client:
206+
mock_client.return_value = MagicMock()
207+
208+
with patch.dict("os.environ", {
209+
"SUPABASE_URL": "https://test.supabase.co",
210+
"SUPABASE_ANON_KEY": "test-key",
211+
"SUPABASE_JWT_SECRET": JWT_SECRET,
212+
}):
213+
from services.auth import SupabaseAuthService
214+
service = SupabaseAuthService()
215+
216+
expired_token = pyjwt.encode(
217+
{"sub": "expired-user", "aud": "authenticated",
218+
"exp": int(time.time()) - 120, "iat": int(time.time()) - 3720},
219+
JWT_SECRET, algorithm="HS256",
220+
)
221+
222+
from services.exceptions import TokenExpiredError
223+
with pytest.raises(TokenExpiredError):
224+
service.verify_jwt(expired_token)
225+
226+
# Should NOT have tried API fallback
227+
service.client.auth.get_user.assert_not_called()
228+
229+
def test_placeholder_secret_nulled_at_startup(self):
230+
"""
231+
Placeholder secrets like 'dev-secret-key' should be detected
232+
at init time and nulled out, forcing API verification path.
233+
"""
234+
with patch("services.auth.create_client") as mock_client:
235+
client = MagicMock()
236+
user = MagicMock()
237+
user.id = "placeholder-user"
238+
user.email = "ph@test.com"
239+
user.user_metadata = {}
240+
response = MagicMock()
241+
response.user = user
242+
client.auth.get_user.return_value = response
243+
mock_client.return_value = client
244+
245+
with patch.dict("os.environ", {
246+
"SUPABASE_URL": "https://test.supabase.co",
247+
"SUPABASE_ANON_KEY": "test-key",
248+
"SUPABASE_JWT_SECRET": "dev-secret-key",
249+
}):
250+
from services.auth import SupabaseAuthService
251+
service = SupabaseAuthService()
252+
253+
# Secret should have been nulled out
254+
assert service.jwt_secret is None
255+
256+
# Should work via API path directly
257+
result = service.verify_jwt("any-token")
258+
assert result["user_id"] == "placeholder-user"
259+
client.auth.get_user.assert_called_once()

0 commit comments

Comments
 (0)