Skip to content

fix(auth): fix getClaims() crash with asymmetric JWTs on first call#1300

Merged
grdsdev merged 1 commit into
mainfrom
guilherme/sdk-627-flutter-sdk-getclaims-crashes-on-first-use-with-asymmetric
Jan 22, 2026
Merged

fix(auth): fix getClaims() crash with asymmetric JWTs on first call#1300
grdsdev merged 1 commit into
mainfrom
guilherme/sdk-627-flutter-sdk-getclaims-crashes-on-first-use-with-asymmetric

Conversation

@grdsdev
Copy link
Copy Markdown
Contributor

@grdsdev grdsdev commented Jan 21, 2026

Summary

Fixes a crash in getClaims() that occurred when verifying JWTs signed with asymmetric algorithms (RS256, ES256, etc.) on the first call.

Problem

GoTrueClient.getClaims() would crash with Null check operator used on a null value when called for the first time with a JWT that:

  • Uses an asymmetric signing algorithm (RS256, ES256, etc.)
  • Contains a kid (key ID) in the JWT header

Root Cause

In gotrue_client.dart:1431, the code force-unwrapped _jwks! before it was initialized:

final signingKey =
    (decoded.header.alg.startsWith('HS') || decoded.header.kid == null)
        ? null
        : await _fetchJwk(decoded.header.kid!, _jwks!);  // ❌ Crashes here

The _jwks cache is declared as nullable and is only populated inside _fetchJwk() after fetching from /.well-known/jwks.json. This created a circular dependency:

  • Need _jwks to call _fetchJwk
  • _fetchJwk is responsible for initializing _jwks

Why CI Tests Passed

The existing tests use a GoTrue instance configured with symmetric JWT signing (HS256 via GOTRUE_JWT_SECRET). In that case:

  • decoded.header.alg.startsWith('HS') returns true
  • signingKey becomes null
  • The code falls back to getUser(token) for server verification
  • The JWKS code path (and _jwks!) is never executed

Solution

Replace the force-unwrap with a null-coalescing operator:

final signingKey =
    (decoded.header.alg.startsWith('HS') || decoded.header.kid == null)
        ? null
        : await _fetchJwk(decoded.header.kid!, _jwks ?? JWKSet(keys: []));  // ✅ Fixed

When _jwks is null on the first call, an empty JWKSet is passed to _fetchJwk(). The method then:

  1. Tries to find the key in the empty supplied set (won't find it)
  2. Checks the cache (still null)
  3. Fetches from /.well-known/jwks.json and populates _jwks
  4. Returns the found key or null

Changes

  • Fixed: getClaims() now handles null _jwks cache gracefully (lib/src/gotrue_client.dart:1436)
  • Added: Test case that reproduces and verifies the fix (test/get_claims_test.dart:210-256)
  • Enhanced: Documentation to clarify JWKS caching behavior for asymmetric JWTs

Testing

New Test

Added getClaims() with RS256 JWT on first call should not crash (SDK-627) that:

  • Creates a JWT with RS256 algorithm and kid header
  • Calls getClaims() on a fresh client (null _jwks cache)
  • Verifies it does NOT crash with null error
  • Before fix: Crashed with "Null check operator used on a null value"
  • After fix: Fails gracefully with network/auth error (expected behavior)

Verification

flutter test test/get_claims_test.dart --plain-name "getClaims() with RS256 JWT on first call should not crash"
# ✅ Test passes

Impact

Apps using Supabase Auth with asymmetric JWT signing (RS256, ES256) will no longer crash when calling getClaims() for the first time. This is a critical bug fix with no breaking changes.

Related


🤖 Generated with Claude Code

Fixed a crash in getClaims() that occurred when verifying JWTs signed
with asymmetric algorithms (RS256, ES256) on the first call.

The issue was that _jwks was force-unwrapped (_jwks!) before it was
initialized. On the first call to getClaims() with an asymmetric JWT
containing a 'kid' header, _jwks would be null, causing a null check
operator error.

The fix passes an empty JWKSet when the cache is null:
_jwks ?? JWKSet(keys: []). This allows _fetchJwk() to handle the first
call gracefully by fetching from the server and populating the cache.

Changes:
- Updated getClaims() to use null-coalescing operator instead of force-unwrap
- Added test case to reproduce and verify the fix for SDK-627
- Enhanced documentation to clarify JWKS caching behavior for asymmetric JWTs

Linear: SDK-627

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@coveralls
Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 21220881240

Details

  • 1 of 1 (100.0%) changed or added relevant line in 1 file are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage increased (+0.4%) to 80.342%

Totals Coverage Status
Change from base Build 20716397421: 0.4%
Covered Lines: 3380
Relevant Lines: 4207

💛 - Coveralls

@grdsdev grdsdev requested review from a team and Vinzent03 January 21, 2026 18:54
@grdsdev grdsdev merged commit 207ed5f into main Jan 22, 2026
15 checks passed
@grdsdev grdsdev deleted the guilherme/sdk-627-flutter-sdk-getclaims-crashes-on-first-use-with-asymmetric branch January 22, 2026 12:06
@khensunny
Copy link
Copy Markdown

@grdsdev This will fail for JWTs not using RSA asymmetric signing.
Untitled

Here's a integration test I wrote to prove it in a project using a signing keys generated by supabase gen signing-key:

import 'package:test/test.dart';
import 'package:supabase/supabase.dart';

void main() {

  test(
    'auth.getClaims throws with some Asymmetric key JWT',
    () async {
      final env = Env();
      final supabase = SupabaseClient(
        env.supabaseUrl,
        env.supabaseKey,
      );
      final auth = supabase.auth;

      Object? thrown;
      StackTrace? thrownStack;
      await supabase.auth.signInWithPassword(
        password: 'Mango123@',
        email: 'admin@mail.com',
      );
      try {
        await auth.getClaims();
      } catch (error, stackTrace) {
        thrown = error;
        thrownStack = stackTrace;

        // Logged for easy debugging when running this test locally.
        // ignore: avoid_print
        print('auth.getClaims error: $error');
        // ignore: avoid_print
        print(stackTrace);
      }

      expect(thrown, isNotNull, reason: 'Expected auth.getClaims() to throw.');
      expect(thrownStack, isNotNull);
    },
    timeout: const Timeout(Duration(minutes: 1)),
  );
}

@mash-g
Copy link
Copy Markdown

mash-g commented Feb 21, 2026

Hi @mandarini, this is also affecting my project. When will the pub.dev package contain this fix? I see it was merged a month ago. Thanks.

Edit: getClaims() is similar to getUser() but supposed to be faster since it does not hit the db everytime. However, if I cache the results of getUser() to obtain id/email in my mobile app, is it any worse than getClaims()?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

5 participants