You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 asMap<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 _jwks is 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') is true
signingKey becomes null
getClaims() falls back to await 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 includes kid in JWT header.
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 _jwks to call _fetchJwk
_fetchJwk is responsible for initializing _jwks
Proposed fix
Avoid force-unwrapping _jwks in getClaims().
Options:
Pass an empty/supplied JWKS when cache is null, and let _fetchJwk fetch + cache:
Use _jwks ?? JWKSet(keys: []) (or equivalent constructor/fromJson)
Change _fetchJwk to 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.json if 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().
@grdsdev
Note
I used AI for the write up but this is a legit bug
Summary
GoTrueClient.getClaims()can throwDartError: Unexpected null value.when verifying JWTs signed with asymmetric algorithms (e.g.RS256,ES256) because it force-unwraps_jwksbefore_jwksis ever initialized.This is a broken assumption in the JWKS verification path.
Affected code
In
gotrue(example from gotrue_client.dart),getClaims()contains:_jwksis declared as nullable and is initialized lazily:_jwksis only set inside_fetchJwk()after fetching/.well-known/jwks.json:So on the first call to
getClaims()for a token requiring JWKS verification,_jwksis stillnull, and_jwks!crashes immediately.Expected behavior
getClaims()should not crash due to internal cache state._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!) is never executedSo tests validate the fallback path but do not cover the asymmetric JWKS path.
Steps to reproduce
RS256) and includeskidin JWT header.await client.getClaims()(orawait client.getClaims(accessToken)).Local GoTrue config used in Supabase Flutter tests (reference)
The local test configuration uses:
This strongly suggests symmetric signing (HS*), which avoids the JWKS branch.
Root cause
getClaims()assumes_jwksis non-null when calling_fetchJwk, but_jwksis lazily populated inside_fetchJwk. This creates a circular initialization dependency:_jwksto call_fetchJwk_fetchJwkis responsible for initializing_jwksProposed fix
Avoid force-unwrapping
_jwksingetClaims().Options:
_fetchJwkfetch + cache:_jwks ?? JWKSet(keys: [])(or equivalent constructor/fromJson)_fetchJwkto accept nullable (JWKSet? suppliedJwks) and treat null as “no supplied keys”.Example intent (pseudo):
This preserves the existing behavior:
/.well-known/jwks.jsonif neededImpact
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 togetUser().