Skip to content

Authentication · Open source

firebase-auth-tanstack-start

Firebase Authentication for TanStack Start with HttpOnly Firebase session cookies: the browser signs in with the Firebase JS SDK, and the server verifies the session cookie or a bearer ID token and puts a typed user with custom claims on the context for server rendering, server functions, server routes and beforeLoad guards.

TanStack Start (React) v0.1.2 Free · MIT
firebase-auth-tanstack-start: Firebase Authentication for TanStack Start with HttpOnly Firebase session cookies: the browser signs in with the Firebase JS SDK, and the server verifies the session cookie or a bearer ID token and puts a typed user with custom claims on the context for server rendering, server functions, server routes and beforeLoad guards.

What it solves

The Firebase JS SDK keeps the sign-in in the browser, so a TanStack Start server render, server function or server route does not know who is signed in.

Firebase's answer is a session cookie made on the server from the browser's ID token, but its documented way to make and verify one is firebase-admin, and the rest is left to each app: login and logout routes, CSRF checks on them, cookie attributes, revocation, and keeping the cookie in step with the browser's sign-in when the user changes or their claims do.

createFirebaseAuth() verifies Firebase session cookies and bearer ID tokens through one of two backends: restBackend(), the default, which checks signatures with Web Crypto through jose against Google's X.509 certificates and calls the Identity Toolkit REST API with a service account, with no firebase-admin; or firebaseAdminBackend(), which goes through firebase-admin. A one-line Start request middleware puts context.user on every request. loginHandler() and logoutHandler() turn an ID token into an HttpOnly session cookie and delete it, and accept only same-origin POSTs. requireUser() throws a 401 or 403 FirebaseAuthError in server functions, requireAuth() guards routes in beforeLoad, and useFirebaseUser() keeps the cookie in step with the browser's Firebase sign-in, including custom claim changes. App Check tokens can be required on login and checked anywhere. It has been tested against the Firebase Auth Emulator with both backends, not against a live Firebase project; the unit tests ran on Node 22 and 24.

  • createFirebaseAuth() (from /server) verifies the Firebase session cookie, or an Authorization: Bearer ID token, and returns a typed FirebaseUser: uid, email, name, picture, phone number, sign-in provider, tenant, auth time, expiry and your custom claims
  • auth.middleware() is the body of a Start request middleware: context.user in every page render, server function and server route, and a cookie that can never become valid again (invalid, expired, revoked, user disabled or deleted) is deleted
  • loginHandler() exchanges a Firebase ID token for a session cookie; logoutHandler() deletes it. Both take POST only and check the Origin header (or Sec-Fetch-Site: same-origin) against the request's own origin; the login body must be application/json
  • Cookie defaults: __session, always HttpOnly, Secure, SameSite=Lax, Path=/, Max-Age 5 days; the lifetime can be set from 5 minutes to 14 days
  • Two backends: Web Crypto through jose plus the Identity Toolkit REST API (the default, no firebase-admin), or firebase-admin through the /admin entry
  • Revocation checks are off by default, as in firebase-admin; checkRevoked: true looks the user up on every request, revokeOnLogout: true revokes the user's refresh tokens at logout, and maxAuthAgeSeconds requires a recent sign-in for new sessions
  • requireUser(context, authorize?) for server functions: throws FirebaseAuthError auth/unauthenticated (401) or auth/forbidden (403) and sets that response status
  • requireAuth() for beforeLoad: redirects signed-out visitors to your login page with ?redirect=, with an optional claim check and forbidden redirect
  • firebaseAuthErrorAdapter, a Start serialization adapter that keeps the error code and status of a FirebaseAuthError across server functions
  • useFirebaseUser() (from /react) posts the browser's ID token to the login route or calls the logout route when the browser and the server disagree about the user or the custom claims
  • Optional App Check verification of the X-Firebase-AppCheck header, required on login by default once configured, and available anywhere through auth.verifyAppCheck()
  • ESM only, marked side-effect free, one runtime dependency (jose); peers @tanstack/react-start ^1.168 and @tanstack/react-router ^1.170, optional peers firebase, firebase-admin and react; Node 22.12 or later (TanStack Start's own minimum); unit tests run on Node 22 and 24

Install

Requires TanStack Start (React).

npm install @amjadiqbal/firebase-auth-tanstack-start firebase

Usage

// src/firebase-auth.server.ts
import { createFirebaseAuth } from '@amjadiqbal/firebase-auth-tanstack-start/server';

export const auth = createFirebaseAuth<{ role?: 'admin' | 'editor' }>({
  projectId: process.env.FIREBASE_PROJECT_ID!,
  serviceAccount: {
    clientEmail: process.env.FIREBASE_CLIENT_EMAIL!,
    privateKey: process.env.FIREBASE_PRIVATE_KEY!,
  },
});

// src/middleware.ts
export const firebaseAuthMiddleware = createMiddleware().server(async ({ request, next }) =>
  next({ context: await auth.middleware({ request }) }),
);

// A server function
export const getProfile = createServerFn().handler(({ context }) => {
  const user = auth.requireUser(context); // auth/unauthenticated (401) when signed out
  return { uid: user.uid, role: user.claims.role };
});

Setup

Install the package in a TanStack Start app with React. With the default backend the server needs no Firebase SDK, because jose is the package's only dependency. Add firebase for the browser sign-in and the /react hook, and firebase-admin only if you use the /admin backend. Peer dependencies are @tanstack/react-start ^1.168 and @tanstack/react-router ^1.170, plus react >=18 for /react.

  1. 1 Default backend (Web Crypto and REST, no firebase-admin): npm install @amjadiqbal/firebase-auth-tanstack-start firebase
  2. 2 firebase-admin backend: npm install @amjadiqbal/firebase-auth-tanstack-start firebase firebase-admin
  3. 3 Create src/firebase-auth.server.ts with createFirebaseAuth({ projectId, serviceAccount: { clientEmail, privateKey } }), or createFirebaseAuth({ backend: firebaseAdminBackend(getAuth(app)) }) with firebaseAdminBackend from /admin. The service account is needed to make session cookies, revoke tokens and use checkRevoked; verifying needs only the project ID. For the Auth Emulator in development, also pass emulatorHost.
  4. 4 Create src/middleware.ts with createMiddleware().server(async ({ request, next }) => next({ context: await auth.middleware({ request }) })).
  5. 5 In src/start.ts, add firebaseAuthErrorAdapter to serializationAdapters, and list createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn' }) before your auth middleware in requestMiddleware, because Start stops adding its CSRF middleware once you list your own request middleware.
  6. 6 Add POST server routes /api/auth/login and /api/auth/logout that return auth.loginHandler(request) and auth.logoutHandler(request). These are the hook's default URLs.
  7. 7 In src/routes/__root.tsx, load context.user through a server function in beforeLoad and call useFirebaseUser(firebaseAuth, { serverUser: user, onSessionChange: () => router.invalidate() }). Create the router with context: { user: null }.
  8. 8 Guard routes with beforeLoad: requireAuth({ redirectTo: '/login' }) and protect data in server functions with auth.requireUser(context).

How tokens are verified

The default backend verifies ID tokens and session cookies itself with Web Crypto through jose. The firebase-admin backend hands both to firebase-admin's verifyIdToken() and verifySessionCookie() and maps their errors onto the package's codes.

  • Only RS256 is accepted, and the token must have a kid header. The key comes from Google's X.509 certificates: https://www.googleapis.com/robot/v1/metadata/x509/[email protected] for ID tokens, https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys for session cookies.
  • Each certificate set is cached for the max-age in the response's Cache-Control header. Without a max-age it is downloaded again on every verification. A failed download is not cached and gives auth/internal-error (500). A kid that is not in the cached set triggers one early download, at most once a minute per certificate URL, because Google rotates its keys; if the kid is still unknown, the token is refused as invalid.
  • Claims checked: aud is the project ID; iss is https://securetoken.google.com/<projectId> for ID tokens or https://session.firebase.google.com/<projectId> for session cookies; sub is non-empty and at most 128 characters; exp has not passed; iat and auth_time are not in the future.
  • Clock skew: clockToleranceSeconds, 0 by default, applies to exp, iat and auth_time in the default backend only. The firebase-admin backend does not pass it on.
  • Emulator mode (emulatorHost on the default backend) accepts only unsigned tokens (alg none, empty signature) and refuses signed ones, sends every Identity Toolkit call to the emulator, and always checks for revoked and disabled users. The default backend never reads the emulator host from the environment; firebase-admin reads FIREBASE_AUTH_EMULATOR_HOST itself.
  • The user is built from the verified claims. claims holds every claim that is not a standard Firebase claim; its type is the one you declare, and nothing checks the values at runtime.

The two backends

Both implement the same four operations: verify an ID token, verify a session cookie, create a session cookie and revoke a user's refresh tokens. What differs is where credentials and keys come from and where they can run.

  • restBackend(), the default: built from projectId, serviceAccount, emulatorHost, clockToleranceSeconds and fetch. Verifying needs only the project ID. Creating session cookies, revoking and checkRevoked need a service account given as clientEmail and privateKey (escaped \n sequences become newlines); it does not use Application Default Credentials or a metadata server, and without a service account those calls fail with auth/credential-required (500).
  • The default backend uses only Web Crypto, fetch and jose, and the package's own code imports no Node built-ins. The /server entry also imports setCookie and setResponseStatus from @tanstack/react-start/server, and Start's server runtime uses node:async_hooks, so an edge platform needs that, as it does for Start itself. It has been run on Node only.
  • firebaseAdminBackend(auth) from /admin takes a firebase-admin Auth instance, so credentials, key fetching and emulator handling are firebase-admin's, and serviceAccount, emulatorHost and clockToleranceSeconds do not apply. firebase-admin error codes the package does not know become auth/internal-error. It needs Node, since firebase-admin does not run on edge runtimes.
  • The firebase-admin backend's unit tests cover its argument passing and error mapping with a stubbed Auth. Both backends run the same five integration tests against the Auth Emulator, and both ran in the test app.
import { firebaseAdminBackend } from '@amjadiqbal/firebase-auth-tanstack-start/admin';
import { createFirebaseAuth } from '@amjadiqbal/firebase-auth-tanstack-start/server';
import { getApps, initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';

export const auth = createFirebaseAuth<AppClaims>({
  backend: firebaseAdminBackend(getAuth(getApps()[0] ?? initializeApp())),
});

Revocation and recent sign-in

checkRevoked is false by default with both backends, as in firebase-admin: a session cookie stays valid until it expires, even after the user signs out elsewhere or is disabled.

  • checkRevoked: true checks every verification, cookie or bearer token, and every login. The default backend calls accounts:lookup and refuses a deleted user (auth/user-not-found), a disabled user (auth/user-disabled) and a token whose auth_time is before the user's validSince (auth/session-cookie-revoked or auth/id-token-revoked), all 401. That is one Identity Toolkit call per request. With the default backend this needs serviceAccount (no Application Default Credentials; without it, auth/credential-required); the firebase-admin backend uses its Admin app's own credentials.
  • revokeOnLogout: true makes logout verify the cookie and revoke the user's refresh tokens (the default backend sets validSince to now through accounts:update; the admin backend calls revokeRefreshTokens()). This signs the user out on every device. If the revocation fails, logout answers with that error and still clears the cookie.
  • A copied cookie stops working after logout only with both options on, because revocation is only checked when checkRevoked is on.
  • maxAuthAgeSeconds is not set by default. When set, login refuses an ID token whose sign-in (auth_time) is older, with auth/recent-sign-in-required (401). Firebase's guide uses 300; it is off here because the hook also uses the login route to renew an expired cookie while the browser is still signed in.

CSRF

The login and logout handlers have their own check. Server functions are covered by Start's CSRF middleware, not by this package.

  • Anything but POST gets 405 auth/method-not-allowed with Allow: POST.
  • When the request has an Origin header, it must exactly match one of allowedOrigins, which defaults to the origin of the request URL. Setting allowedOrigins replaces that default; set it when a proxy changes the scheme or host your server sees.
  • Without an Origin header, Sec-Fetch-Site must be same-origin. With neither, the request is refused. Refusals are 403 auth/csrf-rejected.
  • The login body must be application/json, which a plain HTML form cannot send; anything else is 400 auth/invalid-request.
  • For server functions, register createCsrfMiddleware in src/start.ts as in the setup. The README reports that in its test app a cross-site POST to a server function with a valid session cookie got 403 with it and reached the function without it.

Bearer tokens

With bearer: true (the default), a request with Authorization: Bearer <Firebase ID token> is verified as an ID token, for mobile apps or scripts calling your server routes. The bearer header is checked first: when it is present the cookie is not read at all, and a bearer token that fails gives user: null, with the reason in authenticate()'s error, rather than falling back to the cookie. checkRevoked applies to bearer tokens too. bearer: false accepts only the cookie.

requireUser, route guards and errors

auth.requireUser(context, authorize?) returns context.user or throws a FirebaseAuthError: auth/unauthenticated with status 401 when nobody is signed in, auth/forbidden with status 403 when authorize returns false. Inside a Start request it also sets that response status. Register firebaseAuthErrorAdapter and the error reaches the browser as a FirebaseAuthError with its code and status; without it the browser gets a plain Error with only the message.

  • requireAuth({ redirectTo, searchParam, authorize, forbiddenTo }) is a beforeLoad that reads context.user from the root route. Signed-out visitors go to redirectTo with ?redirect=<the page they wanted>; searchParam renames the parameter and false drops it. It returns { user }, so child routes see a non-null user.
  • When authorize refuses, the guard redirects to forbiddenTo, or without it throws FirebaseAuthError auth/forbidden for the route's error component.
  • A guard also runs in the browser, so it protects pages, not data. Protect data with requireUser() in the server function.
  • auth.authenticate(request) never throws for a bad credential. It returns { user, source: 'session-cookie' | 'bearer' | null, error, clearCookie }. auth.getUser(request) returns just the user.
  • Error statuses: 401 for invalid, expired or revoked ID tokens and session cookies, disabled or deleted users, auth/recent-sign-in-required, auth/unauthenticated and both App Check codes; 400 auth/invalid-request; 403 auth/csrf-rejected and auth/forbidden; 405 auth/method-not-allowed; 500 auth/credential-required and auth/internal-error.
export const Route = createFileRoute('/_authed/admin')({
  beforeLoad: requireAuth<{ claims: AppClaims }>({
    redirectTo: '/login',
    authorize: (user) => user.claims.role === 'admin',
    forbiddenTo: '/forbidden',
  }),
});

Keeping the browser and the server in step

useFirebaseUser(auth, options) from /react listens to Firebase's onIdTokenChanged and treats the browser's sign-in as the source of truth. It returns { user, status: 'loading' | 'signed-in' | 'signed-out', syncing, error, signOut }.

  • Browser signed in as a user the server does not have (serverUser is null or another uid): it posts the ID token to the login route (/api/auth/login by default), then calls onSessionChange.
  • Same uid but different custom claims than serverUser.claims (when serverUser carries claims, as a FirebaseUser does), for example after setCustomUserClaims() and getIdToken(true): it makes a new session the same way.
  • Browser signed out while the server has a session: it posts to the logout route (/api/auth/logout by default), then calls onSessionChange.
  • It also checks again when serverUser changes, for example when an expired cookie made the router reload context.user as null. Without serverUser it calls the server on every page load.
  • signOut() calls the logout route first, then signs out of Firebase. If the logout call fails, error is set and the browser is still signed out of Firebase.
  • A failed call sets error to a SessionSyncError with the route's code and status; the next successful call clears it. getAppCheckToken sends an App Check token with logins.
  • Because the browser decides, an origin where Firebase is signed out deletes a session cookie it shares with another origin, for example two apps on localhost on different ports.

App Check

Pass appCheck: { projectNumber } (the project number, not the project ID). The login handler then requires a valid X-Firebase-AppCheck header, checked after the method and origin check and before the body; requireOnLogin: false turns that off. auth.verifyAppCheck(request) checks the header anywhere else and returns { appId, claims }. The same verifier is used with both backends; it never goes through firebase-admin.

  • The token header must be alg RS256 and typ JWT, and the signature must match a key from https://firebaseappcheck.googleapis.com/v1/jwks.
  • The key set is kept for six hours, as firebase-admin does. A token whose key ID is not in the set triggers one early download, at most once a minute.
  • iss must be https://firebaseappcheck.googleapis.com/<projectNumber>, aud must contain projects/<projectNumber>, sub (the app ID) must be non-empty, and the token must not be expired; no clock tolerance is applied.
  • Tokens are not consumed, so there is no replay protection for limited-use tokens.
  • A missing header gives app-check/missing-token and a bad token app-check/invalid-token, both 401. A failed key download gives auth/internal-error (500). verifyAppCheck() without the appCheck option throws auth/invalid-request.
  • App Check has been tested only in unit tests, because the Auth Emulator has no App Check.
export const auth = createFirebaseAuth({
  projectId: process.env.FIREBASE_PROJECT_ID!,
  appCheck: { projectNumber: '123456789012' },
});

// In the root component
useFirebaseUser(firebaseAuth, {
  serverUser: user,
  getAppCheckToken: async () => (await getToken(appCheck)).token,
});

Compatibility and testing

Peers: @tanstack/react-start ^1.168 and @tanstack/react-router ^1.170, with optional peers firebase >=11 (browser and /react), firebase-admin >=12 (/admin) and react >=18 (/react). The one runtime dependency is jose. ESM only. package.json declares Node 22.12 or later, TanStack Start's own minimum. The unit tests ran on Node 22 and 24; the test app ran on Node 24.

  • 82 unit tests at v0.1.2, all passing on Node 24.15 and 22.23: the default backend with RSA keys and X.509 certificates generated on each run and Google's endpoints mocked (signatures, every claim check, expiry and clock tolerance, key caching by max-age, download on every check without it, one early re-download for a rotated kid, the service-account token exchange, making and revoking sessions, revoked, disabled and deleted users, emulator mode), the login and logout handlers, authenticate, middleware and requireUser, App Check including key rotation, the firebase-admin adapter's argument passing and error mapping, the route guard, and the React hook with the Firebase SDK mocked.
  • 10 integration tests against the Firebase Auth Emulator (npm run test:emulator, firebase-tools 15.34.1), the same five for each backend: verify an ID token and a session cookie made from it, custom claims carried into the cookie, a revoked cookie and ID token refused after revokeRefreshTokens, disabled and deleted users refused, and garbage refused.
  • Tested against the Firebase Auth Emulator with both backends, in a real TanStack Start app that installed the packed tarball, on Node 24: at 0.1.1, Start 1.169.4 and Router 1.170.46 in the dev server and in a production build, and the minimum peers Start 1.168.0 and Router 1.170.0 in a production build; at 0.1.2, Start 1.169.4 in a production build, 18 of 18 HTTP checks plus sign-up, sign-in and sign-out in the browser.
  • A fresh npm install of 0.1.2 from the registry, with an empty npm cache, was also checked against the Auth Emulator: vite build and tsc passed, the 18 HTTP checks passed, the cookie was HttpOnly, the user was rendered on the server after a reload, and a server function answered auth/unauthenticated (401) once signed out.
  • Not tested: a live Firebase project (Google-signed tokens, the service-account token exchange with Google and real session cookies are covered only by unit tests with generated keys), edge runtimes such as Cloudflare Workers, Vercel Edge and Netlify Edge, the test app on Node versions other than 24, browsers other than Chromium, and App Check outside the unit tests. The key refetch for rotated Google keys is covered by unit tests only.
  • 0.1.1 is the first release on npm. 0.1.0 was tagged but never published, because its type declarations bundled a 120 kB copy of TanStack Router's types.
  • A community package, not made or endorsed by Google, Firebase or TanStack. Firebase is a trademark of Google LLC.

Hire me

Need this built into your app, or something custom?

I wrote firebase-auth-tanstack-start. I can wire it into your TanStack app, extend it for your case, or build the feature around it. Tell me what you need and you'll get a plan and a quote, free.

Support

Found a bug or want a feature? Open an issue on GitHub. For help adding it to your own project, use the quote form above.

More TanStack packages

All packages
Query Key Kit: Declare your TanStack Query v5 keys once as a typed tree, get queryOptions()-shaped objects back, and invalidate or update whole branches of the cache with typed helpers.

Query Key Kit

Declare your TanStack Query v5 keys once as a typed tree, get queryOptions()-shaped objects back, and invalidate or update whole branches of the cache with typed helpers.

Start SEO Kit: Typed SEO helpers for TanStack Start: one head() call for title, canonical, Open Graph, Twitter, robots and hreflang tags, a sitemap and robots.txt served from server routes, typed JSON-LD with Product schema, and an optional Open Graph image route.

Start SEO Kit

Typed SEO helpers for TanStack Start: one head() call for title, canonical, Open Graph, Twitter, robots and hreflang tags, a sitemap and robots.txt served from server routes, typed JSON-LD with Product schema, and an optional Open Graph image route.

Table URL State: Keep a TanStack Table v9 table's sorting, column filters, global filter, pagination and column visibility in the URL search params, in both directions, and export the current view to CSV or, from a separate entry point, to XLSX.

Table URL State

Keep a TanStack Table v9 table's sorting, column filters, global filter, pagination and column visibility in the URL search params, in both directions, and export the current view to CSV or, from a separate entry point, to XLSX.

AI SDK Bridge: A third-party migration stepping stone: run Vercel AI SDK provider models (@ai-sdk/* and community providers, provider spec v2, v3 and v4) as TanStack AI 1.0 adapters for chat, embeddings, transcription, speech, image generation and rerank, then swap each one for its native TanStack AI adapter.

AI SDK Bridge

A third-party migration stepping stone: run Vercel AI SDK provider models (@ai-sdk/* and community providers, provider spec v2, v3 and v4) as TanStack AI 1.0 adapters for chat, embeddings, transcription, speech, image generation and rerank, then swap each one for its native TanStack AI adapter.

shopify-app-tanstack-start: Build embedded Shopify apps on TanStack Start with the server API of Shopify's React Router package: session tokens and token exchange, webhook and app proxy verification, the mandatory compliance webhooks and the Billing API, as Start middleware, server functions and server routes.

shopify-app-tanstack-start

Build embedded Shopify apps on TanStack Start with the server API of Shopify's React Router package: session tokens and token exchange, webhook and app proxy verification, the mandatory compliance webhooks and the Billing API, as Start middleware, server functions and server routes.

Table Server Query: Turn TanStack Table v9 sorting, column filters, global search and pagination into server-side queries for Drizzle (Postgres, SQLite), Prisma, Kysely and Supabase/PostgREST, checked against a column allow-list and returned as rows plus a total count.

Table Server Query

Turn TanStack Table v9 sorting, column filters, global search and pagination into server-side queries for Drizzle (Postgres, SQLite), Prisma, Kysely and Supabase/PostgREST, checked against a column allow-list and returned as rows plus a total count.

TanStack AI RAG: A third-party RAG kit for TanStack AI 1.x: token-aware text and Markdown chunking with overlap, ingest through TanStack AI embed() with atomic re-ingest, in-memory and Postgres pgvector stores (pg, postgres.js, PGlite, Drizzle), metadata-filtered retrieval, optional rerank through rerank(), and a server tool for chat() whose citation numbers continue across searches in one run.

TanStack AI RAG

A third-party RAG kit for TanStack AI 1.x: token-aware text and Markdown chunking with overlap, ingest through TanStack AI embed() with atomic re-ingest, in-memory and Postgres pgvector stores (pg, postgres.js, PGlite, Drizzle), metadata-filtered retrieval, optional rerank through rerank(), and a server tool for chat() whose citation numbers continue across searches in one run.

Need something built?

Free quote in 60 seconds