Firebase Auth ↔ Flutter Secure Storage: Interoperability of Token Refresh and Revocation
0 reputation · 27 Jan 2024, 03:28 UTC
0 reputation · 27 Jan 2024, 03:28 UTC
When a Flutter app authenticates with Firebase Auth and persists the refresh token in Flutter Secure Storage, the app must guarantee that only valid, least‑privileged tokens are used for downstream API calls. Firebase automatically refreshes the ID token when it expires, but the SDK does not notify the app of expiration events, and Flutter Secure Storage simply stores the token without any awareness of its validity. Consequently, concurrent API requests can inadvertently use a stale token, and custom claims embedded in the JWT remain until the next refresh, potentially exposing revoked permissions.
Developers face a design decision: either rely solely on Firebase’s automatic refresh mechanism, or augment it with a server‑side revocation endpoint that validates each token against a revocation list. The lack of a native callback for token expiration in Flutter’s OAuth plugins forces developers to poll or implement custom logic, raising concerns about race conditions and subtle security gaps.
What mechanisms can detect token expiration when using Flutter Secure Storage? Should a custom revocation endpoint be invoked on every protected request to enforce least‑privilege access? How can the app prevent race conditions during concurrent API calls that occur around the time a token is refreshed or revoked?
31275 reputation · 27 Jan 2024, 05:22 UTC
Firebase ID tokens are short‑lived JWTs (≈1 hour). The firebase_auth SDK does not fire a dedicated “expired” event. The most reliable client‑side approach is to call FirebaseAuth.instance.currentUser!.getIdTokenResult() before each protected request. The returned IdTokenResult contains expirationTime, issuedAtTime, and authTime. A simple helper can compare expirationTime to the local clock with a small skew (e.g. 60 s) and decide whether to force a refresh.
Future _ensureFreshToken() async {
final user = FirebaseAuth.instance.currentUser;
if (user == null) throw Exception('Not signed in');
final result = await user.getIdTokenResult();
final now = DateTime.now().toUtc();
if (now.isAfter(result.expirationTime!.subtract(const Duration(seconds: 60)))) {
// Token is about to expire – refresh it.
return await user.getIdToken(true);
}
return result.token!;
}
Because flutter_secure_storage merely persists the refresh token, it cannot detect expiry. Treat the token stored there as a static key; only read it once at app start and keep it in memory. Do not store the ID token in secure storage unless you have a very specific use case (e.g. a native module that cannot use the Flutter SDK).
Calling a custom revocation endpoint from the client on every request is unnecessary and fragile. Revocation is a server‑side concept: the Firebase Admin SDK verifies the auth_time against the account’s validSince value. Your backend should perform this check on every protected call using checkRevoked: true.
const decoded = await admin.auth().verifyIdToken(idToken, /* checkRevoked */ true);
If the token was revoked, the Admin SDK throws an auth/id-token-revoked error. The client should interpret a 401 with this error code as a sign that the refresh token is no longer valid, log the user out, and clear the stored refresh token.
Concurrent API calls can trigger multiple refreshes if each one independently calls getIdToken(true). Use a “single‑flight” pattern: keep a single in‑flight Future that represents the current refresh operation. All callers await that future instead of starting a new one.
class AuthTokenProvider {
Future? _refreshInFlight;
final _lock = Object();
Future token() async {
final user = FirebaseAuth.instance.currentUser;
if (user == null) throw Exception('Not signed in');
final result = await user.getIdTokenResult();
final now = DateTime.now().toUtc();
if (now.isBefore(result.expirationTime!.subtract(const Duration(seconds: 60)))) {
return result.token!;
}
return _withLock(() async {
if (_refreshInFlight != null) return await _refreshInFlight!;
_refreshInFlight = user.getIdToken(true);
try {
return await _refreshInFlight!;
} finally {
_refreshInFlight = null;
}
});
}
}
When a request receives a 401 with an error code indicating token expiry or revocation, force a refresh once with getIdToken(true) and retry the request a single time. This guarantees that only one network call refreshes the token even under heavy concurrency.
firebase_auth and flutter_secure_storage to a stable version and test upgrade paths.getIdTokenResult() to decide when to refresh; do not store ID tokens in secure storage.checkRevoked:true; treat a 401 from the server as a sign to log out.exp and local clock difference to aid debugging.With these patterns, the app uses only valid, least‑privileged tokens, avoids stale claims, and eliminates race‑condition hazards without the overhead of per‑request revocation checks.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.