Skip to content

fix: Adds support for SharedPreferencesAsync - #1164

Closed
romaingyh wants to merge 6 commits into
supabase:mainfrom
romaingyh:shared_prefs_async
Closed

fix: Adds support for SharedPreferencesAsync#1164
romaingyh wants to merge 6 commits into
supabase:mainfrom
romaingyh:shared_prefs_async

Conversation

@romaingyh

Copy link
Copy Markdown

What kind of change does this PR introduce?

It's both a bug fix and feature.

9 months ago with version 2.3.0, shared_preferences package added SharedPreferencesAsync and SharedPreferencesWithCache to replace the legacy (and deprecated in future) SharedPreferences. See here

Supabase flutter uses by default the legacy one with SharedPreferencesLocalStorage.

Problem is that on some platform like Windows, the legacy one and new one are not compatible. If the developer made the migration to SharedPreferencesAsync then the supabase's local storage is broken.

Example :
I'm using SharedPreferencesAsync in my app. After sign in, the sb-x-auth-token is saved in saved-preferences.json by supabase's SharedPreferences. After that, any call to my app's SharedPreferencesAsync will overwrite the token and user have to sign in on next app launch. In practice it can lead to sign in every time he opens the app if you use shared preferences frequently in your code.

What is the new behavior?

I added a boolean flag useSharedPreferencesAsync to SharedPreferencesLocalStorage. There wasn't very much changes as every methods are already async in LocalStorage interface.

If you prefer two distinct classes SharedPreferencesLocalStorage and SharedPreferencesAsyncLocalStorage I can do this

@Vinzent03

Vinzent03 commented May 5, 2025

Copy link
Copy Markdown
Collaborator

I can confirm that this issue exists, as I encountered this as well in a recent project, but didn't have the time to investigate more and come up with a pr. At that time, I solved it by manually providing my own storage implementation using the async shared preferences.
But I'm really unsure about the current design of the solution and will have to think about it a bit.

@coveralls

coveralls commented May 5, 2025

Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 15563534678

Details

  • 0 of 8 (0.0%) changed or added relevant lines in 1 file are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage decreased (-0.1%) to 75.487%

Changes Missing Coverage Covered Lines Changed/Added Lines %
packages/supabase_flutter/lib/src/local_storage.dart 0 8 0.0%
Totals Coverage Status
Change from base Build 15549173376: -0.1%
Covered Lines: 2907
Relevant Lines: 3851

💛 - Coveralls

@romaingyh romaingyh changed the title Adds support for SharedPreferencesAsync fix: Adds support for SharedPreferencesAsync May 5, 2025
@romaingyh

romaingyh commented May 5, 2025

Copy link
Copy Markdown
Author

I can confirm that this issue exists, as I encountered this as well in a recent project, but didn't have the time to investigate more and come up with a pr. At that time, I solved it by manually providing my own storage implementation using the async shared preferences. But I'm really unsure about the current design of the solution and will have to think about it a bit.

I thought of another solution like this :

Code
/// A [LocalStorage] implementation that implements SharedPreferences as the
/// storage method.
class SharedPreferencesLocalStorage extends LocalStorage {
  late final SharedPreferences _prefs;

  SharedPreferencesLocalStorage({required this.persistSessionKey});

  final String persistSessionKey;

  static const _useWebLocalStorage =
      kIsWeb && bool.fromEnvironment("dart.library.js_interop");

  @override
  Future<void> initialize() async {
    if (!_useWebLocalStorage) {
      WidgetsFlutterBinding.ensureInitialized();
      _prefs = await SharedPreferences.getInstance();
    }
  }

  @override
  Future<bool> hasAccessToken() async {
    if (_useWebLocalStorage) {
      return web.hasAccessToken(persistSessionKey);
    }
    return _prefs.containsKey(persistSessionKey);
  }

  @override
  Future<String?> accessToken() async {
    if (_useWebLocalStorage) {
      return web.accessToken(persistSessionKey);
    }
    return _prefs.getString(persistSessionKey);
  }

  @override
  Future<void> removePersistedSession() async {
    if (_useWebLocalStorage) {
      web.removePersistedSession(persistSessionKey);
    } else {
      await _prefs.remove(persistSessionKey);
    }
  }

  @override
  Future<void> persistSession(String persistSessionString) {
    if (_useWebLocalStorage) {
      return web.persistSession(persistSessionKey, persistSessionString);
    }
    return _prefs.setString(persistSessionKey, persistSessionString);
  }
}

/// A [LocalStorage] implementation that implements SharedPreferencesAsync as the
/// storage method.
class SharedPreferencesAsyncLocalStorage extends LocalStorage {
  late final SharedPreferencesAsync _prefs;

  SharedPreferencesAsyncLocalStorage({required this.persistSessionKey});

  final String persistSessionKey;

  static const _useWebLocalStorage =
      kIsWeb && bool.fromEnvironment("dart.library.js_interop");

  @override
  Future<void> initialize() async {
    if (!_useWebLocalStorage) {
      WidgetsFlutterBinding.ensureInitialized();
      _prefs = SharedPreferencesAsync();
    }
  }

  @override
  Future<bool> hasAccessToken() async {
    if (_useWebLocalStorage) {
      return web.hasAccessToken(persistSessionKey);
    }
    return _prefs.containsKey(persistSessionKey);
  }

  @override
  Future<String?> accessToken() async {
    if (_useWebLocalStorage) {
      return web.accessToken(persistSessionKey);
    }
    return _prefs.getString(persistSessionKey);
  }

  @override
  Future<void> removePersistedSession() async {
    if (_useWebLocalStorage) {
      web.removePersistedSession(persistSessionKey);
    } else {
      await _prefs.remove(persistSessionKey);
    }
  }

  @override
  Future<void> persistSession(String persistSessionString) {
    if (_useWebLocalStorage) {
      return web.persistSession(persistSessionKey, persistSessionString);
    }
    return _prefs.setString(persistSessionKey, persistSessionString);
  }
}

where the dev has to opt-in for SharedPreferencesAsyncLocalStorage when he initializes supabase.

I didn't choose this implementation in my PR because the two classes are pretty much the same except for shared prefs init.

@romaingyh

Copy link
Copy Markdown
Author

I think CI test fails because with Flutter 3.19 the resolvable shared_preferences package version is < 2.3.0 but not sure about this

@sai-chandra22

Copy link
Copy Markdown

@Vinzent03 , Can you exactly tell me what is the solution you have done for this solution

@sai-chandra22

sai-chandra22 commented May 28, 2025

Copy link
Copy Markdown
await sb.Supabase.initialize(
    url: ApiKeys.supabaseUrl,
    anonKey: ApiKeys.supabaseAnonKey,
    authOptions: sb.FlutterAuthClientOptions(
        autoRefreshToken: true,
        localStorage: sb.SharedPreferencesLocalStorage(
            persistSessionKey: sb.supabasePersistSessionKey)
        ),
  );

the refresh_token_already_used error is coming, though supabase is handling auto refresh - This comes when user again opens the app (Cold start) after the token expiry time, then instead of emitting token refreshed state, the error is coming!

@Vinzent03

Copy link
Copy Markdown
Collaborator

If we want to provide a LocalStorage implementation that uses SharedPreferencesAsync we need shared_preferences v2.3.0, which requires Dart v3.4.0, but we currently test and support until Dart v3.3.0. So we would need to bump Flutter to v3.22.0 for Dart v3.4.0. But this version is already one year old and this is an actual issue, because the session persistence is broken.
The only other way would be to only provide an example implementation for users to copy and add themselves.
But because we can't detect whether they use the new or old api we would have to stick with the old api for the moment and mention it in the docs that if they use the new shared_preferences api they need the new LocalStorage implementation anyway.
@dshukertjr What is your opinion?

Either way @romaingyh I prefer the two distinct classes implementation.

@dshukertjr

Copy link
Copy Markdown
Member

@Vinzent03

But this version is already one year old and this is an actual issue, because the session persistence is broken.

Broken in what way?

@romaingyh

Copy link
Copy Markdown
Author

Either way @romaingyh I prefer the two distinct classes implementation.

Done, PR has now two classes : SharedPreferencesLocalStorage and SharedPreferencesAsyncLocalStorage

@Vinzent03

Copy link
Copy Markdown
Collaborator

@dshukertjr The way it's described in the pr description. Writes to SharedPreferencesAsync overwrite values written by SharedPreferences.getInstance(), so restoring the session fails at startup.

@dshukertjr

Copy link
Copy Markdown
Member

@romaingyh I know this blows up the scope of this PR, but I'm okay bumping the minimum Flutter/ Dart version to 3.220/ 3.4.0 and to just use SharedPreferencesAsync from now on. One thing that we should be doing is upon initialization, we make sure that existing tokens in the legacy SharedPreferences are transferred to SharedPreferencesAsync upon initialization.

@romaingyh
romaingyh force-pushed the shared_prefs_async branch from 0470383 to ccbd41f Compare June 10, 2025 15:20
@romaingyh

Copy link
Copy Markdown
Author

@dshukertjr I bumped flutter/dart to 3.22/3.4.0 and replaced SharedPreferences with SharedPreferencesAsync. I also added the token migration at init but not tested

@Vinzent03

Copy link
Copy Markdown
Collaborator

But using the new async variant by default causes existing usage in the user's code of the old variant to break, doesn't it? This is a heavy breaking change.

@romaingyh

Copy link
Copy Markdown
Author

Yes as Async prefs overwrite the legacy prefs (I'm sure on windows but not for other platforms) I think there is a risk of user losing it's preferences if he upgrade supabase but still use legacy prefs in his code

@romaingyh
romaingyh force-pushed the shared_prefs_async branch from ccbd41f to fda1304 Compare July 27, 2025 19:04
@romaingyh

Copy link
Copy Markdown
Author

I rebased

@longtimedeveloper

Copy link
Copy Markdown

@romaingyh do you know when your fix will be available in the public release of Supabase? Thank you.

I updated my app today and broke this Windows sign in as described here.

@romaingyh

romaingyh commented Aug 25, 2025

Copy link
Copy Markdown
Author

@romaingyh do you know when your fix will be available in the public release of Supabase? Thank you.

I updated my app today and broke this Windows sign in as described here.

Sorry no I'm not an officiel maintainer of this repo. We're currently waiting for the main maintainer to publish new releases.

Here is what I'm doing for now. In pubspec :

  supabase_flutter:
    git:
      url: https://github.com/romaingyh/supabase-flutter.git
      ref: shared_prefs_async
      path: packages/supabase_flutter

and use this in supabase initialization auth options :

       localStorage: SharedPreferencesLocalStorage(
          persistSessionKey: "sb-${Uri.parse(url).host.split(".").first}-auth-token",
        ),

You can also override supabase to have the last fixes :

  # TODO: Remove when supabase > 2.8.0 published on pub.dev, including https://github.com/supabase/supabase-flutter/pull/1196
  supabase:
    git:
      url: https://github.com/romaingyh/supabase-flutter.git
      path: packages/supabase

Copilot AI review requested due to automatic review settings January 13, 2026 21:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates the SharedPreferencesLocalStorage class from the legacy SharedPreferences API to the newer SharedPreferencesAsync API to address compatibility issues on platforms like Windows where the two storage mechanisms are incompatible. The change prevents users from being logged out when using SharedPreferencesAsync in their application code.

Changes:

  • Updated shared_preferences dependency from ^2.0.0 to ^2.3.0
  • Migrated SharedPreferencesLocalStorage to use SharedPreferencesAsync instead of the legacy SharedPreferences
  • Added automatic migration logic to transfer existing sessions from legacy to async storage
  • Bumped minimum SDK version from 3.3.0 to 3.4.0 and Flutter from 3.19.0 to 3.22.0

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
packages/supabase_flutter/pubspec.yaml Updates dependency constraints for SDK, Flutter, and shared_preferences package
packages/supabase_flutter/lib/src/local_storage.dart Migrates SharedPreferencesLocalStorage to use SharedPreferencesAsync and adds migration logic

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +9 to +10
sdk: '>=3.4.0 <4.0.0'
flutter: '>=3.22.0'

Copilot AI Jan 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SDK and Flutter version bumps are significant breaking changes (from 3.3.0 to 3.4.0 and 3.19.0 to 3.22.0). These version requirements should be justified - are they necessary for SharedPreferencesAsync support? If not, consider using the original minimum versions to avoid unnecessarily restricting the user base.

Suggested change
sdk: '>=3.4.0 <4.0.0'
flutter: '>=3.22.0'
sdk: '>=3.3.0 <4.0.0'
flutter: '>=3.19.0'

Copilot uses AI. Check for mistakes.
Comment on lines +87 to +95
final legacyPrefs = await SharedPreferences.getInstance();

if (legacyPrefs.containsKey(persistSessionKey)) {
final accessToken = legacyPrefs.getString(persistSessionKey);

if (accessToken != null) {
await legacyPrefs.remove(persistSessionKey);
await _prefs.setString(persistSessionKey, accessToken);
}

Copilot AI Jan 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The migration logic should handle errors gracefully. If the migration fails (e.g., due to storage access issues), the error could leave the session in an inconsistent state or prevent initialization. Consider wrapping the migration in a try-catch block and logging any errors, allowing initialization to proceed even if migration fails.

Suggested change
final legacyPrefs = await SharedPreferences.getInstance();
if (legacyPrefs.containsKey(persistSessionKey)) {
final accessToken = legacyPrefs.getString(persistSessionKey);
if (accessToken != null) {
await legacyPrefs.remove(persistSessionKey);
await _prefs.setString(persistSessionKey, accessToken);
}
try {
final legacyPrefs = await SharedPreferences.getInstance();
if (legacyPrefs.containsKey(persistSessionKey)) {
final accessToken = legacyPrefs.getString(persistSessionKey);
if (accessToken != null) {
await legacyPrefs.remove(persistSessionKey);
await _prefs.setString(persistSessionKey, accessToken);
}
}
} catch (e, stackTrace) {
debugPrint(
'Error while migrating access token for key "$persistSessionKey": $e');
debugPrint(stackTrace.toString());

Copilot uses AI. Check for mistakes.
}
}

Future<void> _maybeMigrateAccessToken() async {

Copilot AI Jan 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new migration logic in _maybeMigrateAccessToken lacks test coverage. Since the existing tests for SharedPreferencesLocalStorage use setMockInitialValues which may not properly test the migration from legacy SharedPreferences to SharedPreferencesAsync, add tests to verify: 1) successful migration when legacy data exists, 2) no-op when legacy data doesn't exist, and 3) proper cleanup of legacy storage after migration.

Copilot uses AI. Check for mistakes.

if (accessToken != null) {
await legacyPrefs.remove(persistSessionKey);
await _prefs.setString(persistSessionKey, accessToken);

Copilot AI Jan 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable accessToken appears to hold a Supabase auth/session token and is being stored in cleartext using SharedPreferencesAsync, which typically persists data in an unencrypted file on the device. Because this is a bearer token, any attacker or malicious app able to read the underlying shared preferences storage (e.g., via device compromise, backup extraction, or sandbox escape) can reuse this token to impersonate the user against Supabase, leading to account compromise. Severity: HIGH. Confidence: 8

Copilot uses AI. Check for mistakes.
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been inactive for 90 days.
It will be automatically closed in 14 days if there is no further activity.

If you plan to continue working on this PR, please leave a comment to keep it open.

@github-actions github-actions Bot added the Stale label Apr 19, 2026
@github-actions

github-actions Bot commented May 3, 2026

Copy link
Copy Markdown
Contributor

This pull request was automatically closed due to inactivity.

If you'd like to continue this work, please reopen the PR or create a new one.

@spydon

spydon commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

@romaingyh thanks for your work on this, I missed that it was auto closed.
It has now been implemented in #1680 and will be available in the v3 version of the SDK.

spydon added a commit that referenced this pull request Aug 12, 2026
…nc (#1680)

## What kind of change does this PR introduce?

Bug fix, breaking change.

## What is the current behavior?

`SharedPreferencesLocalStorage` and
`SharedPreferencesGotrueAsyncStorage`, the storage implementations
`Supabase.initialize` uses by default, write through the legacy
`SharedPreferences` API. On Windows and Linux both APIs rewrite the same
`shared_preferences.json` in full from their own in-memory cache, so an
app that has migrated its own code to `SharedPreferencesAsync` has its
session dropped and the user is signed out on the next launch.

Closes #1276, and supersedes #1164.

## What is the new behavior?

Both implementations use `SharedPreferencesAsync`. The minimum Flutter
version is already well past the 3.22 that API needs, so nothing had to
be bumped.

`SharedPreferencesLocalStorage.initialize()` moves a session written by
v2 over to the new store, so existing users stay signed in:

- It runs once. A `<persistSessionKey>-legacy-migrated` key in the new
store records that it happened, so the legacy store is read at most once
and a signed-out user is never signed back in.
- Deleting the legacy entry is not enough on its own to get that
guarantee: on Windows and Linux a later write through either API can
bring a deleted entry back. The delete still happens, but only so a
stale token does not lie around.
- A session already in the new store wins, so a stale legacy one never
overwrites it.
- The legacy store is `reload()`ed first, since the instance is shared
with the app and may have been loaded before the session was last
written.

On web nothing changes: the session still goes into
`window.localStorage` under the same key, as it did before.

`SharedPreferencesGotrueAsyncStorage` no longer needs its initialization
`Completer`, since `SharedPreferencesAsync` has no asynchronous setup.
It is created on first use so that constructing the storage does not
require the bindings to be initialized yet.

The pending PKCE code verifier is not migrated. It is valid for the
length of one sign-in round trip, and the failure mode of losing it is a
retryable sign-in.

## Superseding #1164

That PR made the same core change. Here is where each point from its
review thread landed:

| Raised there | Here |
| --- | --- |
| Windows loses the session when the app uses `SharedPreferencesAsync` |
Fixed, both default storages moved to the async API |
| Transfer the existing token to the new store on initialization | Done,
and made one-time, order-safe and precedence-aware as described above |
| Needs Flutter 3.22 / Dart 3.4 for `SharedPreferencesAsync` | Already
satisfied on `main`: Flutter 3.35, Dart 3.9, `shared_preferences:
^2.5.5` |
| Prefer two storage classes over a `useSharedPreferencesAsync` flag |
Neither. v3 uses the async API unconditionally, which is what was asked
for on that thread instead |
| A heavy breaking change for apps that still use the legacy API
themselves | `MIGRATION.md` now states per platform what mixing costs,
including that a session write can drop the app's own legacy
preferences, and ships a `LocalStorage` to copy for staying on the
legacy store |
| `SharedPreferencesGotrueAsyncStorage` left on the legacy API | Moved
over too, so the PKCE code verifier does not keep the mixing problem
alive |
| No test coverage (0 of 8 changed lines) | Six migration tests, listed
below |

One comment on that thread reported `refresh_token_already_used` on a
cold start after token expiry. That is an auth-refresh problem rather
than a storage one, so it is deliberately out of scope here.

## Additional context

`MIGRATION.md` documents the change, including a `LocalStorage`
implementation to copy for anyone who wants to keep the session in the
legacy store for now.

Tests cover the migration: a legacy session is moved over, the legacy
entry is gone afterwards, an existing session in the new store wins, a
session that was signed out of is not restored on the next launch, the
same holds when both stores had a session, and a legacy entry written
back after the migration is ignored. They live in a non-web test file,
since on web there is nothing to migrate. The other test files now mock
both shared_preferences APIs through a `mockSharedPreferences` helper.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Mobile session persistence now uses the modern asynchronous
preferences API.
  * Existing sessions migrate automatically from legacy storage.
* **Bug Fixes**
  * Prevented session loss and storage conflicts during migration.
  * Signed-out sessions are not restored.
  * Preserved browser `localStorage` behavior on web.
* **Documentation**
* Added migration guidance and updated custom storage documentation,
including compatibility considerations for legacy storage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants