-
-
Notifications
You must be signed in to change notification settings - Fork 274
Description
Note
I used AI for the write up but this is a legit bug
Summary
GoTrueClient.getClaims() can throw DartError: Unexpected null value. when verifying JWTs signed with asymmetric algorithms (e.g. RS256, ES256) because it force-unwraps _jwks before _jwks is ever initialized.
This is a broken assumption in the JWKS verification path.
Affected code
In gotrue (example from gotrue_client.dart), getClaims() contains:
final signingKey =
(decoded.header.alg.startsWith('HS') || decoded.header.kid == null)
? null
: await _fetchJwk(decoded.header.kid!, _jwks!);_jwks is declared as nullable and is initialized lazily:
JWKSet? _jwks;
DateTime? _jwksCachedAt;_jwks is only set inside _fetchJwk() after fetching /.well-known/jwks.json:
final jwksResponse = await _fetch.request('$_url/.well-known/jwks.json', ...);
final jwks = JWKSet.fromJson(jwksResponse as Map<String, dynamic>);
_jwks = jwks;
_jwksCachedAt = now;So on the first call to getClaims() for a token requiring JWKS verification, _jwks is still null, and _jwks! crashes immediately.
Expected behavior
getClaims()should not crash due to internal cache state.- On first use, if
_jwksis null, it should fetch/.well-known/jwks.json, cache it, and verify the JWT (or fall back to server verification if applicable).
Actual behavior
When using an asymmetric JWT with a kid:
getClaims()throws a runtime null error:DartError: Unexpected null value.
This happens before any network call to retrieve JWKS occurs.
Why Supabase Flutter CI tests may still pass
The CI/local tests can pass without hitting this bug if the test GoTrue instance signs tokens with HS256 (symmetric signing via GOTRUE_JWT_SECRET). In that case:
decoded.header.alg.startsWith('HS')istruesigningKeybecomesnullgetClaims()falls back toawait getUser(token)(server verification path)- JWKS code path (and
_jwks!) is never executed
So tests validate the fallback path but do not cover the asymmetric JWKS path.
Steps to reproduce
- Configure a GoTrue instance that returns JWTs signed with an asymmetric algorithm (e.g.
RS256) and includeskidin JWT header. - Call
await client.getClaims()(orawait client.getClaims(accessToken)). - Observe crash on the first invocation.
Local GoTrue config used in Supabase Flutter tests (reference)
The local test configuration uses:
services:
gotrue:
image: supabase/auth:v2.182.1
environment:
GOTRUE_JWT_SECRET: '37c304f8-51aa-419a-a1af-06154e63707a'
...This strongly suggests symmetric signing (HS*), which avoids the JWKS branch.
Root cause
getClaims() assumes _jwks is non-null when calling _fetchJwk, but _jwks is lazily populated inside _fetchJwk. This creates a circular initialization dependency:
- Need
_jwksto call_fetchJwk _fetchJwkis responsible for initializing_jwks
Proposed fix
Avoid force-unwrapping _jwks in getClaims().
Options:
- Pass an empty/supplied JWKS when cache is null, and let
_fetchJwkfetch + cache:- Use
_jwks ?? JWKSet(keys: [])(or equivalent constructor/fromJson)
- Use
- Change
_fetchJwkto accept nullable (JWKSet? suppliedJwks) and treat null as “no supplied keys”.
Example intent (pseudo):
final supplied = _jwks ?? emptyJwkSet();
final signingKey = shouldUseJwks
? await _fetchJwk(decoded.header.kid!, supplied)
: null;This preserves the existing behavior:
- try supplied jwks
- try cached jwks if fresh
- fetch from
/.well-known/jwks.jsonif needed
Impact
Apps using Supabase Auth with asymmetric JWT signing can crash at runtime when getClaims() is invoked, even though this method is documented as a “faster” alternative to getUser().