Auth Flow Blueprint — React 19 + Express + Firebase
This is Part 2 of my series on building secure Firebase apps. In Part 1, I focused on building a secure backend. Now, I’m tackling the next step — connecting the frontend to the backend, and making sure we fully leverage our backend flow and Firebase’s secure session cookie structure without dropping the ball.
Think of it as a personal checklist for fast, headache-free development. It’s a record of what works, what breaks, and what needs to be locked in so I don’t end up stuck in “prompt hell” next time I do this.
⸻
Goal:
Cookie-based authentication for a React 19 + RTK Query frontend and an Express + Firebase Admin backend.
Works locally and in production without leaking tokens.
0) Moving Parts
Client (React 19)
└─ RTK Query base (credentials: 'include')
└─ /auth/login mutation -> POST email/password
└─ /auth/me query -> reads session (httpOnly) cookie
└─ Global 401 handler -> redirect to /login (store `from` in sessionStorage)
Server (Express 5)
└─ POST /auth/login -> Firebase Auth -> idToken -> createSessionCookie -> Set-Cookie
└─ GET /auth/me -> verifySessionCookie -> return user
└─ POST /auth/logout-> clearCookie('session')
└─ verifySessionCookie middleware guards protected routes
1) Server: Minimal Endpoints
// login
const { idToken } = await signInWithEmailAndPassword(email, password);
const sessionCookie = await admin.auth().createSessionCookie(idToken, { expiresIn: 5 * 24 * 60 * 60 * 1000 });
res.cookie('session', sessionCookie, {
httpOnly: true,
secure: isProd || sameSite === 'none',
sameSite, // 'lax' (same-site) or 'none' (cross-site, requires HTTPS)
maxAge: 5 * 24 * 60 * 60 * 1000,
path: '/',
});
return res.status(200).json({ message: 'Login successful', user: decodedUser });
// me
const cookie = req.cookies?.session;
if (!cookie) return res.status(401).json({ error: 'Unauthorized' });
const decoded = await admin.auth().verifySessionCookie(cookie, true);
return res.status(200).json({ user: pick(decoded, ['uid','email','name','picture']) });
// logout
res.clearCookie('session', { httpOnly: true, secure: isProd || sameSite==='none', sameSite, path: '/' });
res.status(200).json({ message: 'Logged out' });
CORS (single mount):
const ORIGINS = ['http://localhost:5173', process.env.FRONTEND_ORIGIN!].filter(Boolean);
app.use(cors({
origin(origin, cb){ if(!origin) return cb(null,true); return ORIGINS.includes(origin) ? cb(null,true) : cb(new Error('CORS')); },
credentials: true,
}));
app.use(cookieParser());
app.use(express.json());
Guard (use on protected routes):
export async function verifySessionCookie(req,res,next){
const c = req.cookies?.session;
if(!c) return res.status(401).json({ error: 'Unauthorized' });
try { (req as any).user = await admin.auth().verifySessionCookie(c, true); next(); }
catch { return res.status(401).json({ error: 'Unauthorized or expired' }); }
}
2) Client: RTK Base + Auth Endpoints
// base (src/app/rtk.ts)
const raw = fetchBaseQuery({ baseUrl: API_BASE, credentials: 'include' });
const base = async (args, api, extra) => {
const res = await raw(args, api, extra);
if (res.error && (res.error as any).status === 401) {
try { sessionStorage.setItem('postLoginRedirect', location.pathname + location.search); } catch {}
location.replace('/login');
}
return res;
};
export const api = createApi({ reducerPath:'api', baseQuery: base, tagTypes:['Me'] });
// auth (src/features/auth/authSlice.ts)
export const authApi = api.injectEndpoints({
endpoints: (b) => ({
me: b.query<{user:any}, void>({ query: ()=>('/auth/me'), providesTags:['Me'] }),
login: b.mutation<{message:string; user:any}, {email:string; password:string}>({
query: (body)=>({ url:'/auth/login', method:'POST', body }), invalidatesTags:['Me']
}),
logout: b.mutation<{message:string}, void>({
query: ()=>({ url:'/auth/logout', method:'POST' }), invalidatesTags:['Me']
}),
})
});
export const { useMeQuery, useLoginMutation, useLogoutMutation } = authApi;
Login page redirect (sessionStorage):
const stored = sessionStorage.getItem('postLoginRedirect');
let to = stored && stored.startsWith('/') ? stored : '/drivers';
sessionStorage.removeItem('postLoginRedirect');
navigate(to, { replace: true });
Protected route wrapper:
function Protected({ children }:{children:React.ReactNode}){
const { data, isLoading } = useMeQuery();
if(isLoading) return <FullScreenSpinner/>;
if(!data?.user) return <Navigate to="/login" replace/>;
return <>{children}</>;
}
3) Env & Cookie Matrix
| Context | Frontend Origin | Backend Origin | Cookie flags | Notes |
|-----------------|---------------------------|----------------------------|----------------------------------|-------------------------------|
| Dev (same-site) | http://localhost:5173 | http://localhost:5050 | SameSite=lax, Secure=false | Easiest; or Vite proxy /api |
| Dev (cross-site)| http://localhost:5173 | https://localhost:5050 | SameSite=none, Secure=true | Requires HTTPS in dev |
| Prod (same-site)| https://app.example.com | https://app.example.com | SameSite=lax, Secure=true | |
| Prod (cross-site)| https://app.example.com | https://api.example.com | SameSite=none, Secure=true | Must enable HTTPS + CORS |
If deploying behind a proxy (Cloud Run/Nginx), add
app.set('trust proxy', 1).
4) Test Checklist (Do This Every Time)
- [ ]
POST /auth/loginreturns 200 and setsSet-Cookie: session=...; HttpOnly - [ ] Browser shows
sessioncookie (Application tab),Path=/ - [ ]
GET /auth/meimmediately after login → 200 with{ user } - [ ] Protected route returns 401 without cookie
- [ ] Frontend baseQuery has
credentials: 'include' - [ ] CORS allowlist exactly matches the frontend origin (no
*) - [ ] No tokens/idToken in URL or JSON body responses
5) Common Pitfalls (Fix Fast)
- Cookie rejected: cross-site +
SameSite=Lax→ useSameSite=None; Secure; HTTPS. - Still 401 after login: cookie flags wrong, CORS mismatch, or forgot
credentials:'include'. - Long login URL: you’re appending tokens/
fromto query; move it tosessionStorage. - Express 5 route crash: no
*in paths; avoidapp.options('*', ...)→ not allowed.
6) Minimal Vite Proxy (Makes Dev Same-Site)
// vite.config.ts
export default defineConfig({
server: {
host: 'localhost',
port: 5173,
proxy: { '/api': { target: 'http://localhost:5050', changeOrigin: true } }
}
});
// client API_BASE = '/api'
📝 Takeaways
- Implement
/auth/login,/auth/me,/auth/logoutfirst — everything else depends on them. - Configure CORS once, with credentials enabled.
- Use RTK base query with a global 401 redirect to
/login. - Guard protected pages with
<Protected/>. - Test cookie +
/auth/mebefore building any UI.