Flutter Authentication with Firebase + JWT โ€” The Full Flow
DEV Community

Flutter Authentication with Firebase + JWT - The Full Flow

Why Two Tokens?

Firebase sign-in gives you a Firebase ID token - a signed JWT that says "this user is authenticated with Firebase." You could theoretically use it to call your backend directly, and Firebase provides the admin SDK to verify it. But in practice you want a second token for three reasons:

  • Your backend should not need Firebase infrastructure. If your API trusts only Firebase-issued tokens, it is coupled to Firebase forever, and your JWT secret or OAuth flow lives inside the mobile app's dependency chain.
  • You want your own claims. Your token can carry roles, tenant IDs, plan limits, anything your app knows about the user that Firebase does not.
  • You want control over expiry and revocation. Firebase ID tokens last up to an hour and are awkward to revoke. Your own short-lived access token, issued on demand, gives you a clean revocation story: blacklist it, or just let it expire.

The Flow

The flow is a chain: Firebase token โ†’ your backend verifies it โ†’ your backend issues your JWT โ†’ your app sends your JWT on every call. One direction, two hops, no circular trust.

Flutter App
โ”€โ”€(1) Firebase sign-in โ”€โ”€โ–ถ Firebase
   โ–ฒโ”‚                        โ”‚
   โ”‚(2) ID token             โ”‚
   โ”‚โ–ผ                        โ”‚
   โ”‚ Your Backend (verify + issue JWT)
   โ”‚(3) your JWT
   โ”‚
   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
(4) every API call sends: Authorization: Bearer <your JWT>

Step 1: Sign the User In with Firebase

Add firebase_auth to your pubspec.yaml, initialize Firebase with your generated firebase_options.dart, and sign in:

Future<User> signIn(String email, String password) async {
  final credential = await FirebaseAuth.instance.signInWithEmailAndPassword(
    email: email,
    password: password,
  );
  return credential.user!;
}

Once the user is signed in, you have an IdTokenResult. This is the token your backend will verify:

final user = FirebaseAuth.instance.currentUser!;
final idToken = await user.getIdToken(); // Firebase ID token, ~1h lifetime
final idTokenString = idToken!.token!; // the JWT string

Never store this token in shared preferences. Keep it in memory and re-fetch it when you need it - getIdToken() refreshes automatically when expired, so there is no reason to persist it.

Step 2: Verify the ID Token and Issue Your JWT

On your backend, verify the Firebase ID token with the Firebase Admin SDK. The Admin SDK automatically fetches and caches Google's public keys, so verification is one call:

import admin from 'firebase-admin';
import jwt from 'jsonwebtoken';

if (!admin.apps.length) {
  admin.initializeApp({
    credential: admin.credential.applicationDefault(),
  });
}

app.post('/api/auth/exchange', async (req, res) => {
  const { idToken } = req.body;
  try {
    // 1. Verify the Firebase token. Throws if invalid or expired.
    const decoded = await admin.auth().verifyIdToken(idToken);

    // 2. Look up or create the user, load roles/plan from your DB.
    const user = await db.users.findOrCreate({ firebaseUid: decoded.uid });

    // 3. Issue YOUR access token with YOUR claims.
    const accessToken = jwt.sign(
      {
        sub: user.id,
        firebaseUid: decoded.uid,
        roles: user.roles,
        plan: user.plan,
      },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }, // short-lived access token
    );

    // 4. Return it to the app.
    res.json({ accessToken });
  } catch (err) {
    res.status(401).json({ error: 'invalid token' });
  }
});

Three things matter here. The access token lives 15 minutes, not an hour or a day - short lifetimes are the entire security model, because a stolen token is only useful for 15 minutes. It carries only your claims. And the Firebase token is verified server-side with the Admin SDK, so no client can forge a login by just sending a made-up payload.

Step 3: Store the Token and Attach It to Every Call

Keep the token in memory or in a secure store. For a 15-minute token you do not need secure storage complexity - memory is fine because you will exchange it again after expiry. Build an HTTP client that automatically attaches the header:

class ApiClient {
  Future<Map<String, dynamic>> get(String path) async {
    final token = await _getAccessToken(); // in-memory, refreshed on demand
    final res = await http.get(
      Uri.parse('$baseUrl$path'),
      headers: { 'Authorization': 'Bearer $token' },
    );
    if (res.statusCode == 401) throw UnauthorizedException();
    return jsonDecode(res.body) as Map<String, dynamic>;
  }
}

The interceptor pattern - a single place where every request gets the token - is the whole trick. If you add the header in 40 call sites, you will miss one, and that one will be the one you debug for a day.

Step 4: Protect Your Backend Routes

Now protect your API with a middleware that verifies your JWT:

function requireAuth(req, res, next) {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: 'missing token' });
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch {
    return res.status(401).json({ error: 'invalid or expired token' });
  }
}

app.get('/api/orders', requireAuth, async (req, res) => {
  const orders = await db.orders.findMany({ userId: req.user.sub });
  res.json(orders);
});

Notice the last line: the route reads the user ID from req.user.sub, never from a client-supplied field. That is how a user is prevented from reading another user's orders - the identity comes from the verified token, not from the request body.

Step 5: Protect Routes in Flutter

In Flutter, gate navigation on both the Firebase session and the presence of your access token. A single root-level listener keeps it consistent:

StreamBuilder<User?>(
  stream: FirebaseAuth.instance.authStateChanges(),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const SplashScreen();
    }
    final user = snapshot.data;
    return user == null ? const LoginScreen() : const AppShell();
  },
)

For screens that call your backend, check the token before rendering and route to login on UnauthorizedException. The mistake most tutorials make is treating Firebase presence as "logged in" - it is only half the story. If the exchange token is missing or expired, the user is technically signed into Firebase but cannot call your API, and your app must handle that state explicitly instead of crashing into a wall of 401s.

Step 6: Handle Expiry and Refresh Gracefully

Your access token expires every 15 minutes. When a 401 comes back, the clean flow is:

  1. Ask the user to re-verify with Firebase (getIdToken() refreshes it automatically), or re-prompt for the password only when Firebase itself requires it.
  2. Re-exchange for a fresh access token.
  3. Retry the original request once.
Future<Map<String, dynamic>> getWithRetry(String path) async {
  try {
    return await _get(path);
  } on UnauthorizedException {
    await _refreshAccessToken(); // re-exchange
    return await _get(path); // retry exactly once
  }
}

The "retry exactly once" rule matters. Unbounded retry loops on 401 are how a bad token burns your API budget and how a revoked account silently hammers your server. One retry, then surface the error.

Step 7: Sign Out Properly

Sign-out is two actions, in order. Clear your backend token first, then sign out of Firebase:

Future<void> signOut() async {
  await _clearAccessToken(); // drop your JWT
  await FirebaseAuth.instance.signOut(); // drop the Firebase session
  Navigator.pushAndRemoveUntil(
    context,
    MaterialPageRoute(builder: (_) => const LoginScreen()),
    (_) => false,
  );
}

If you want real server-side revocation, call the Firebase Admin SDK's revokeRefreshTokens(uid) on logout, then re-check decodedToken.auth_time against your own revocation list on each exchange. That is a stricter security posture than most apps need, but it is the pattern to know about when a leaked-credential incident actually happens.

Sensitive Operations Need Recent Sign-In

Firebase blocks certain operations - changing email, changing password, deleting the account - unless the user signed in recently. Handle requires-recent-login explicitly instead of letting it surface as a confusing error:

Future<void> changePassword(String oldPassword, String newPassword) async {
  final user = FirebaseAuth.instance.currentUser!;
  try {
    await user.updatePassword(newPassword);
  } on FirebaseAuthException catch (e) {
    if (e.code == 'requires-recent-login') {
      final credential = EmailAuthProvider.credential(
        email: user.email!,
        password: oldPassword,
      );
      await user.reauthenticateWithCredential(credential);
      await user.updatePassword(newPassword);
    } else {
      rethrow;
    }
  }
}

That code shows up exactly once, in production, at the moment a user is trying to fix their own account. Handle it before you ship, not in a support ticket.

The Startup Sequence

On app launch, do not assume a cached Firebase session is a working session. Run the exchange once and let the result decide the route:

Future<void> bootstrapSession() async {
  final user = FirebaseAuth.instance.currentUser;
  if (user == null) {
    _goToLogin();
    return;
  }
  try {
    final idToken = (await user.getIdToken())?.token;
    final accessToken = await exchangeForAccessToken(idToken!);
    _storeAccessToken(accessToken);
  } catch (_) {
    await FirebaseAuth.instance.signOut();
    _goToLogin();
  }
}

The discipline: a present Firebase user is not a working session. If the exchange fails, sign out and show login - you have now handled the revoked-account and expired-token cases on the first screen instead of the tenth.

When to Skip the Custom JWT

The two-token flow is right when your backend is a real API. If your app is pure Firebase - all reads and writes go through Firestore, Storage, and Cloud Functions with no separate HTTP API of your own - skip the exchange. Your Firebase ID token, verified server-side by Cloud Functions via the Admin SDK, already does the job, and the extra hop is just latency and failure surface.

Add your own JWT the day you have a server-side endpoint that needs to trust a caller, and no sooner.

The Pitfalls

  • Trusting the client-supplied user ID. If a route reads userId from the request body instead of the verified token, any authenticated user can read and write anyone else's data. This is the number one auth bug in production, and it has nothing to do with JWT libraries - it is a discipline bug.
  • Long-lived access tokens. A 30-day JWT is a permanently reusable key if leaked. Keep access tokens short (15 minutes is my default) and rely on the exchange to refresh. Your users will never notice; the attacker's window shrinks by 99.9%.
  • Storing tokens insecurely. Never put JWTs in shared preferences or, worse, in the Firebase idToken in localStorage on web. Use secure storage (or memory) and re-fetch on demand.
  • Forgetting the refresh path. Apps that work at login but fail after 20 minutes are almost always missing the token refresh flow. Test the app with a 10-minute token expiry in development so you actually exercise it.
  • Ignoring auth_time after revocation. If you revoke a user, the ID token can still look valid until it expires. Check the token's auth_time and compare it with the revocation timestamp in your exchange route.
  • Verifying your JWT with the wrong secret. A JWT signed with the Firebase service account key and a JWT signed with your app secret are different animals. Keep JWT_SECRET in an environment variable, rotate it on a schedule, and never commit it.
  • Exchanging over plain HTTP. The exchange and every API call must be HTTPS. A token sent over plain HTTP is the same as publishing it - add transport security and a strict certificate check on mobile.

The Full-Flow Checklist

When you ship Flutter + Firebase auth backed by your own API, walk this list:

  • [ ] Firebase ID token verified server-side with the Admin SDK
  • [ ] Your backend issues its own short-lived JWT with your claims
  • [ ] Access token expiry is 15 minutes or less
  • [ ] Authorization header attached in one shared client, not per call site
  • [ ] Backend routes read identity only from the verified token
  • [ ] One retry on 401, then surface the error
  • [ ] Sign-out clears your token before Firebase sign-out
  • [ ] JWT_SECRET in env, never committed
  • [ ] Revocation path exists and checks auth_time
  • [ ] Tested with an artificially short token lifetime

This is the complete pipeline: Firebase for identity, your backend for trust, and the JWT as the handshake between them. It is more moving parts than a login screen, but every part exists for a reason - and the reasons are exactly the bugs that will find you three months after launch if you skip them.

Build it once, protect every route through the middleware, and the auth layer stops being the thing you worry about. Then you can get back to building the product.

Gulshan Yad

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.