Skip to content

Commit 9515fda

Browse files
committed
fix: auth resilience -- graceful JWT fallback, placeholder detection, postmortem tests
Production auth outage: SUPABASE_JWT_SECRET was a placeholder ('dev-secret-key') on Railway. PR #253 switched JWT verification from Supabase API calls to local decode. Local decode against a wrong secret silently failed, causing 401 for all authenticated users. Layer 1 -- Graceful fallback (services/auth.py): When local JWT decode fails with InvalidTokenError, try Supabase API verification before giving up. Wrong secret now degrades to slow auth (API call per request) instead of broken auth (401). Expired tokens and missing claims still fail immediately (no point retrying those). Layer 2 -- Startup validation: - auth.py __init__: detects placeholder secrets (< 32 chars or known placeholders like 'dev-secret-key'). Nulls them out and logs error with instructions to get the real secret from Supabase dashboard. Forces API verification path. - startup_checks.py: warns if JWT secret is suspiciously short. Layer 3 -- Postmortem tests (3 new): - test_wrong_secret_falls_back_to_api: THE production scenario. JWT secret is set but wrong. Verifies auth succeeds via API fallback instead of returning 401. - test_expired_token_does_not_fallback: expired is expired. Verifies we don't waste an API call on genuinely expired tokens. - test_placeholder_secret_nulled_at_startup: verifies 'dev-secret-key' gets detected and nulled at init time. Updated 2 existing tests: - test_wrong_secret_raises_error -> test_wrong_secret_falls_back_not_raises - test_wrong_audience_raises_error -> test_wrong_audience_falls_back_not_raises Both now verify fallback behavior instead of asserting errors. 292 tests pass (289 + 3 new). Zero flake8 errors.
1 parent d6563d1 commit 9515fda

3 files changed

Lines changed: 182 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: 137 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,111 @@ 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+
token = _make_token(
217+
{"sub": "expired-user"},
218+
secret=JWT_SECRET,
219+
)
220+
# Manually create expired token
221+
expired_token = pyjwt.encode(
222+
{"sub": "expired-user", "aud": "authenticated",
223+
"exp": int(time.time()) - 120, "iat": int(time.time()) - 3720},
224+
JWT_SECRET, algorithm="HS256",
225+
)
226+
227+
from services.exceptions import TokenExpiredError
228+
with pytest.raises(TokenExpiredError):
229+
service.verify_jwt(expired_token)
230+
231+
# Should NOT have tried API fallback
232+
service.client.auth.get_user.assert_not_called()
233+
234+
def test_placeholder_secret_nulled_at_startup(self):
235+
"""
236+
Placeholder secrets like 'dev-secret-key' should be detected
237+
at init time and nulled out, forcing API verification path.
238+
"""
239+
with patch("services.auth.create_client") as mock_client:
240+
client = MagicMock()
241+
user = MagicMock()
242+
user.id = "placeholder-user"
243+
user.email = "ph@test.com"
244+
user.user_metadata = {}
245+
response = MagicMock()
246+
response.user = user
247+
client.auth.get_user.return_value = response
248+
mock_client.return_value = client
249+
250+
with patch.dict("os.environ", {
251+
"SUPABASE_URL": "https://test.supabase.co",
252+
"SUPABASE_ANON_KEY": "test-key",
253+
"SUPABASE_JWT_SECRET": "dev-secret-key",
254+
}):
255+
from services.auth import SupabaseAuthService
256+
service = SupabaseAuthService()
257+
258+
# Secret should have been nulled out
259+
assert service.jwt_secret is None
260+
261+
# Should work via API path directly
262+
result = service.verify_jwt("any-token")
263+
assert result["user_id"] == "placeholder-user"
264+
client.auth.get_user.assert_called_once()

0 commit comments

Comments
 (0)