Skip to content

Bug: GoTrueClient.getClaims() crashes on first use for RS*/ES* JWTs (null _jwks) #1286

@khensunny

Description

@khensunny

@grdsdev

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 _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

  1. Configure a GoTrue instance that returns JWTs signed with an asymmetric algorithm (e.g. RS256) and includes kid in JWT header.
  2. Call await client.getClaims() (or await client.getClaims(accessToken)).
  3. 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 _jwks to call _fetchJwk
  • _fetchJwk is responsible for initializing _jwks

Proposed fix

Avoid force-unwrapping _jwks in getClaims().

Options:

  1. Pass an empty/supplied JWKS when cache is null, and let _fetchJwk fetch + cache:
    • Use _jwks ?? JWKSet(keys: []) (or equivalent constructor/fromJson)
  2. 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().


Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions