Repository navigation
fix(security): on Flutter web a logout during a refresh's access-token write is undone - the secure-storage write lands after the clear and leaves a live access token on a signed-out browser #986
Description
Found by the G3 security review of #984 (for #939), from the code at main be66fbd and flutter_secure_storage_web 2.1.1 (the version in pubspec.lock).
Summary
The session-generation guard in AuthRepositoryImpl.refreshToken() (lib/features/auth/data/repositories/auth_repository_impl.dart:324-330) only works if the token store applies calls in the order they are issued. The code comment at lines 317-323 says so: "Where the platform store handles calls in order (flutter_secure_storage on Android and iOS), the clear also lands after it; elsewhere this narrows the window rather than closing it." That residual has never been filed, and on Flutter web, a shipped target (web/, deploy-web.yml), the order is reversed:
FlutterSecureStorageWeb.writeawaits_getEncryptionKeyandcrypto.subtle.encrypt(both WebCrypto, async) beforelocalStorage.setItem.FlutterSecureStorageWeb.deleteanddeleteAllcalllocalStorage.removeItemsynchronously.
So a delete issued after a pending write runs before that write lands.
Exposure path
- A refresh returns. The generation check at line 324 passes, and
cacheToken(NEW_ACCESS)starts.SecureTokenStore.setAccessToken->write, which is now waiting on WebCrypto. - Logout starts.
logout()invalidates the generation, awaits the remote call (this fails at once when offline), thenclearCache()->clearAllTokens()->removeItem.AuthInterceptor._logoutUser()(auth_interceptor.dart:829) goes straight toclearAllTokens()with no network await in between. Either way the removal runs while the encryption is still pending. - The encryption finishes, and
setItemwrites NEW_ACCESS after the clear. - The check at line 327 fails, so the refresh token is not written.
refreshToken()returnsSESSION_TERMINATED. The interceptor rejects without_logoutUser()(auth_interceptor.dart:503-516), by design. Nothing clears the store again.
Result: a signed-out browser holds a freshly minted access token for the previous user. It is the only credential, with no refresh token and no cached user, so getCurrentUser() reports signed out and the UI shows the login screen. But AuthInterceptor.onRequest (auth_interceptor.dart:327-328) attaches any non-empty stored access token to every API-origin request. Every request the signed-out app makes, including the next user's sign-in request, carries the previous user's bearer token until it expires. And per #797, that token sits in localStorage next to its AES key.
The same reordering already applied to the interceptor's own setAccessToken (auth_interceptor.dart:544). #169 and #290 recorded that as tier 3 and did not claim it. This issue claims it for web from the plugin source. It is a race window, not deterministic, so P1 hardening.
What is and is not verified
- Verified by reading the code: the plugin's write is encrypt-then-
setItemand its delete is a synchronousremoveItem(~/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-2.1.1/lib/flutter_secure_storage_web.dart,write~221-247,delete~57-62,deleteAll~66-84). The repository never re-clears after a stale return. - Not verified, because it needs a real browser: the WebCrypto vs. remote-logout timing that makes step 3 land in practice. Desktop (macOS/Windows/Linux) ordering is likewise unverified.
Acceptance criteria
- A tier-1 test uses a token store that models the web plugin: a write completes after a delete issued later. In that test, a
logout()issued whilerefreshToken()'s access-token write is pending ends withgetAccessToken()andgetRefreshToken()both null, andrefreshToken()returnsAuthErrorCodes.sessionTerminated. - The same holds for a forced logout through
AuthInterceptor._logoutUser()landing during the interceptor'ssetAccessToken(auth_interceptor.dart:544). - A session that started during the stale refresh is not damaged. A
login()that completes after the stale write keeps its own tokens, so the fix must not "clear on stale" blindly. Serializing token-store operations (a clear waits for in-flight writes) is one approach that meets this. - The comment at
auth_repository_impl.dart:317-323no longer describes a window that the store leaves open.
Activity
docs(security): state the ordering guarantee on ITokenStore, and the …
fix(security): apply token-store operations in the order they are iss…
changelog: entry for #1001
koniz-dev commentedon Oct 30, 2026
Security review (G3) of 9ddf193 (#1001): PASS for the shipped wiring. One new hardening finding was filed.
What I checked, all by reading the code. Nothing ran on a device. Web storage behaviour is tier 3.
tokenStoreProvideris a non-autoDisposeProvider, and nothing invalidates it.authInterceptorProvider,CacheInterceptor(throughauthInterceptor.tokenStore),AuthLocalDataSourceImplandAuthRepositoryImplall share that one instance. A startup retry disposes the old container beforerunApp, so two live containers never exist. The only otherSecureTokenStore(inlib/is theAuthInterceptorfallback (auth_interceptor.dart:51), and the app never uses it.AuthRepositoryImpl.logout()andAuthInterceptor._logoutUser()both callsessionGeneration.invalidate()synchronously beforeclearAllTokens(). The guarded writes, the repository refresh and the interceptor'ssetAccessTokenat line 544, have no await between their check and the issue. So every write that passes its check is queued ahead of the clear. No rawSecureStorageServiceaccess toauth_token/refresh_tokenexists outside the store. The migration service touches secure storage only during startup, before the session is restored.Authorization(_noCacheHeaderssecondary guard).New finding: #1004 (P1). The ordering holds per instance only. The documented legacy
AuthInterceptor(secureStorageService:)path builds a second store with its own queue, which reopens this race for any adopter who uses it.Already filed, not re-filed: #985 (login/register writes are unguarded), #1002, #1003.
evidence: issue-986 token store applies operations in order
koniz-dev commentedon Oct 30, 2026
Fixed by #1001 (squash 9ddf193).
SecureTokenStorequeues every token read, write and delete behind those issued before it, andITokenStoredocuments the ordering an implementation must keep.logout()duringrefreshToken()'s access-token write leaves both tokens null and returnssessionTerminatedg2-and-tests.log: "a logout during the refresh write leaves no token behind", insecure_token_store_order_test.dart, which runs over a storage whose writes land after an async step and whose deletes land at once.mutant-unqueued-store.log: it fails against the unqueued storeAuthInterceptor._logoutUser()during the interceptor'ssetAccessToken_logoutUser()ends inclearAllTokens()on the same shared store (tokenStoreProvider, confirmed by G3). "a clear issued after a pending write lands after it" pins that exact ordering, and it fails in the same mutant log. No interceptor-level test drives itlogin()completing after the stale write keeps its own tokensrefreshToken()comment no longer describes an open windowauth_repository_impl.dartnow names the store's ordering guaranteeG2:
audit_template.logexits 0, andcheck_epic_coverageandcheck_deliverableexit 0. PR checks:pr1001-checks.txt. G3 passed (comment above). It filed #1004: the queue is per instance, andAuthInterceptor(secureStorageService:)builds a second store. The pre-merge review led to #1002 (a hung platform call holds the queue) and #1003 (SecureStorageService.clear()bypasses it). Real browser timing is tier 3 and is not claimed. No screenshots were produced or cited. Self-QA: each artifact was opened and matches its row.