fix!: reject invalid JWTs immediately instead of falling through to next auth mode#35
Conversation
|
First of all, thx for your contribution 💚 From your PR you're assuming that allow list should be Applying cc @tomaspozo |
Thanks for the review! To clarify, the PR does not change the allow list from OR to AND. The OR logic (first match wins) is fully preserved. The change only affects one case: when a credential is present but invalid. Today, sending a garbage JWT like "this.is.garbage" to an endpoint with allow: ['user', 'always'] silently succeeds with authType: 'always', because the catch {} in the user mode returns null (same as "no credential"), causing fallthrough. At least from tests. The fix distinguishes between:
Without this fix, one can send any garbage in the Authorization: Bearer header to downgrade from user to always mode, and if the handler branches on ctx.auth.authType to use supabaseAdmin for always-mode requests, that's an RLS bypass? |
tomaspozo
left a comment
There was a problem hiding this comment.
Thanks for digging into this @homanp, and for the clear explanation. I agree that a present-but-invalid JWT shouldn't silently downgrade. Distinguishing "credential present but invalid" from "credential absent" is easier to reason about than collapsing both into fallthrough. Good catch!
A couple of small asks before we merge:
-
Test for
allow: ['user', 'secret']with an invalid JWT + valid secret apikey, mirroring the['user', 'public']case. This is where the behavior change is most visible to existing callers, today they'd authenticate viasecret, after this PR they'd getInvalidCredentialsError, so locking it in with a test would be great. -
Test for the
sub-missing path. The change atverify-credentials.ts:168-170also flipsreturn null→return INVALIDwhen a JWT verifies cryptographically but has nosubclaim (orsubisn't a string). Consistent with the rest of the PR, but it's a second behavior change worth covering. Sign a token withoutsuband assertInvalidCredentialsError. Worth a brief note in the PR description too. -
BREAKING CHANGE:footer in the commit. Since this changes the outcome for clients sending an invalid token alongside a valid apikey, something like:
BREAKING CHANGE: when multiple auth modes are allowed, a present-but-invalid
JWT is now rejected with InvalidCredentialsError instead of falling through
to the next mode. Clients that previously relied on silent fallthrough
(e.g., stale token + valid apikey) must now either omit the Authorization
header or refresh the token.
Happy to handle the docs updates on my end once this lands. Thanks again!
Got it, will update shortly. |
…xt auth mode Introduce an INVALID sentinel so tryMode can distinguish "credential present but failed" from "credential absent." The main loop now short- circuits on INVALID instead of silently trying the next allowed mode. Also covers the case where a JWT verifies cryptographically but has no sub claim (or sub isn't a string) -- previously returned null (fallthrough), now returns INVALID (reject). BREAKING CHANGE: when multiple auth modes are allowed, a present-but-invalid JWT is now rejected with InvalidCredentialsError instead of falling through to the next mode. Clients that previously relied on silent fallthrough (e.g., stale token + valid apikey) must now either omit the Authorization header or refresh the token.
9f0b612 to
a56ff4f
Compare
|
@tomaspozo done. |
Align documentation with the behavior introduced in the fix!: commit on this branch. Make clear across user-facing docs, TSDoc, and SKILL that: - A mode is "tried" only when its credential is actually present, so a request with no Authorization header still falls through. - A JWT that is present but fails verification (malformed, expired, wrong signature, missing sub) rejects with InvalidCredentialsError — it does not silently fall through to another allowed mode. Touches README, docs/auth-modes, docs/security, docs/error-handling, docs/api-reference, skills/supabase-server/SKILL, and TSDoc on the Allow type, WithSupabaseConfig.allow, and verifyCredentials.
|
Pushed a
No behavior changes and no new tests — the existing test suite (114 passing) still covers the runtime side. Bundling the docs into this PR so they ship in the same release as the breaking change. |
What kind of change does this PR introduce?
Bug fix
What is the current behavior?
When
allowis an array (e.g.['user', 'always']), an invalid JWT (malformed, expired, wrong signature) silently falls through to the next auth mode viacatch { return null }intryMode. A garbage JWT like"this.is.garbage"succeeds withauthType: 'always'instead of returning a 401.This happens because the code does not distinguish between "no credential present" (legitimate fallthrough) and "credential present but invalid" (should reject).
What is the new behavior?
tryModenow returns a three-value result:AuthResultcredential matched, auth succeedednullno relevant credential present, safe to try next modeINVALIDcredential present but failed verification, reject immediatelyWhen a JWT is present but fails verification,
verifyCredentialsimmediately returns anINVALID_CREDENTIALSerror instead of trying the next mode.public/secretmodes are unchanged, theapikeyheader is shared between both, so a key that doesn't matchsecretshould still be allowed to trypublic.Note: This PR also changes the behavior when a JWT verifies cryptographically but has no sub claim (or sub isn't a string), previously this returned null (fallthrough to next mode), now it returns INVALID (immediate rejection). This is consistent with the present-but-invalid principle applied throughout the rest of the PR.
Tests added:
['user', 'always']→ rejected['user', 'always']→ rejected['user', 'always']→ correctly falls through toalways['user', 'public']+ valid apikey → rejectedAll 112 tests pass. Build and typecheck pass.