Authentication & Security

JWT Handling: Decoding, Validation & Refresh

16 min Lesson 5 of 12

JWT Handling: Decoding, Validation & Refresh

JSON Web Tokens (JWTs) are the backbone of modern stateless authentication. When a user signs in with Firebase Authentication, the Firebase SDK issues a signed ID token — a compact, URL-safe JWT that encodes the user's identity and claims. In this lesson you will learn to parse and verify these tokens on the client using the dart_jsonwebtoken package, implement silent token refresh so sessions never expire unexpectedly, and forward the Bearer token to a REST API with every authenticated request.

Note: Client-side JWT parsing is used for reading claims (uid, email, expiry) and deciding whether to refresh proactively. Never trust client-side JWT validation as a security boundary — your backend must independently verify the token using Firebase Admin SDK or Google's public keys.

Anatomy of a Firebase ID Token

A JWT consists of three Base64URL-encoded parts separated by dots: header.payload.signature. The payload contains standard claims you will read frequently:

  • sub — the Firebase UID (stable, unique per user)
  • email / email_verified — user's email address and verification status
  • iat (issued-at) and exp (expiry) — Unix timestamps; Firebase tokens expire after 1 hour
  • aud — your Firebase project ID (validates the token is for your app)
  • firebase.sign_in_provider — e.g., password, google.com

Decoding a Token with dart_jsonwebtoken

Add the package to your pubspec.yaml:

pubspec.yaml dependency

dependencies:
  dart_jsonwebtoken: ^2.8.0
  firebase_auth: ^5.0.0
  http: ^1.2.0

The JWT.decode() method parses the token without signature verification — useful for extracting claims on the client. To verify the signature you supply a SecretKey or RSAPublicKey; for Firebase tokens the issuer uses RS256, but client-side you typically decode without verification (the Firebase SDK has already authenticated the session).

Decoding and reading Firebase JWT claims

import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:firebase_auth/firebase_auth.dart';

/// Returns the decoded payload map from the current user's ID token.
/// Throws if there is no signed-in user.
Future<Map<String, dynamic>> getTokenClaims() async {
  final user = FirebaseAuth.instance.currentUser;
  if (user == null) throw StateError('No signed-in user');

  // Force-refresh=false: use cached token when still valid
  final idToken = await user.getIdToken(false);

  // Decode without signature verification (client-side only)
  final jwt = JWT.decode(idToken!);
  final payload = jwt.payload as Map<String, dynamic>;

  final uid      = payload['sub']   as String;
  final email    = payload['email'] as String?;
  final expiry   = DateTime.fromMillisecondsSinceEpoch(
                     (payload['exp'] as int) * 1000);
  final provider = (payload['firebase'] as Map)['sign_in_provider'];

  print('UID: $uid  |  expires: $expiry  |  provider: $provider');
  return payload;
}

/// Returns true when the cached token will expire within [threshold].
bool isTokenExpiringSoon(Map<String, dynamic> payload,
    {Duration threshold = const Duration(minutes: 5)}) {
  final exp = DateTime.fromMillisecondsSinceEpoch(
      (payload['exp'] as int) * 1000);
  return DateTime.now().add(threshold).isAfter(exp);
}

Silent Token Refresh

Firebase tokens expire every 60 minutes. Rather than forcing the user to re-authenticate, you implement silent refresh: call getIdToken(true) to force a new token before the current one expires. The best practice is to refresh proactively — check expiry before each API call and refresh if fewer than 5 minutes remain.

AuthService with proactive silent refresh

import 'dart:async';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:firebase_auth/firebase_auth.dart';

class AuthService {
  final FirebaseAuth _auth = FirebaseAuth.instance;

  /// Returns a fresh (or still-valid) ID token string.
  /// Automatically force-refreshes if expiring within 5 minutes.
  Future<String> getFreshToken() async {
    final user = _auth.currentUser;
    if (user == null) throw StateError('Not authenticated');

    // Obtain the token (may be cached)
    final rawToken = await user.getIdToken(false);
    final payload  = JWT.decode(rawToken!).payload as Map<String, dynamic>;

    final exp = DateTime.fromMillisecondsSinceEpoch(
        (payload['exp'] as int) * 1000);
    final needsRefresh =
        DateTime.now().add(const Duration(minutes: 5)).isAfter(exp);

    if (needsRefresh) {
      // Force a new token from Firebase servers
      return await user.getIdToken(true) ?? '';
    }
    return rawToken;
  }

  /// Listens to Firebase ID token changes and emits fresh tokens.
  Stream<String?> get tokenStream =>
      _auth.idTokenChanges().asyncMap((user) async {
        if (user == null) return null;
        return await user.getIdToken(false);
      });
}
Tip: Subscribe to FirebaseAuth.instance.idTokenChanges() in your auth state notifier. Firebase automatically refreshes the token in the background and emits a new value, so your app always holds a current token without any polling logic.

Forwarding the Bearer Token to a REST API

Once you have a fresh token, attach it to every HTTP request as an Authorization: Bearer <token> header. The cleanest pattern is a thin HTTP wrapper or a custom http.BaseClient that injects the header automatically.

AuthenticatedClient — auto-injects Bearer token

import 'dart:async';
import 'package:http/http.dart' as http;
import 'auth_service.dart'; // contains AuthService

/// An http.BaseClient that injects the Firebase Bearer token
/// into every request and silently refreshes when needed.
class AuthenticatedClient extends http.BaseClient {
  final AuthService _authService;
  final http.Client _inner;

  AuthenticatedClient(this._authService,
      {http.Client? inner})
      : _inner = inner ?? http.Client();

  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) async {
    // Obtain a fresh (auto-refreshed if needed) token
    final token = await _authService.getFreshToken();

    // Clone request and add the Authorization header
    request.headers['Authorization'] = 'Bearer $token';
    request.headers['Content-Type']  = 'application/json';

    return _inner.send(request);
  }

  @override
  void close() => _inner.close();
}

// --- Usage ---
// final client = AuthenticatedClient(AuthService());
// final response = await client.get(
//   Uri.parse('https://api.example.com/profile'),
// );
// print(response.body);
Warning: Never log or store raw ID tokens in plaintext (e.g., SharedPreferences). They are short-lived credentials. Use flutter_secure_storage if you must persist any token, and always transmit over HTTPS.

Handling Token Errors Gracefully

Network failures, revoked tokens, or clock-skew can cause FirebaseAuthException. Catch specific error codes and redirect the user appropriately:

  • user-token-expired — the refresh token has been revoked; redirect to login
  • network-request-failed — no connectivity; show offline UI, retry later
  • user-disabled — account suspended; sign out immediately

Summary

In this lesson you learned to decode Firebase JWTs using dart_jsonwebtoken to read claims such as UID, email, and expiry. You implemented a proactive silent-refresh strategy via getIdToken(true) and built a reusable AuthenticatedClient that injects the Authorization: Bearer header into every REST call. In the next lesson you will secure local storage and handle token revocation for a complete production-grade auth flow.