Coding my First Authentication system

Table of contents
- HOW TO BUILD AN AUTHENTICATION SYSTEM, USING FIREBASE AUTH
- Prerequisites
- Step 1: The Frontend - User Login and Sending the Token
- Step 2: The Backend - Creating the Session Cookie
- Step 3: The Backend Middleware - Protecting Routes
- Step 4: The Backend - Defining Protected Routes
- Understanding Session Expiry
- Tying It Together: Escaping the "Loop" and Why Cookies Won
- Conclusion
If you're building a web application, one of the first things you'll need is a way for users to log in and for you to protect certain parts of your site – the "members-only" areas. I've been figuring this out myself, and honestly, it was a bit of a puzzle for a while, but I've landed on a really solid way to do it using Firebase Authentication on the frontend and Firebase Admin SDK on the backend, specifically using cookies for managing the session.
This approach gives you the best of both worlds: Firebase handles the complex user management, and your backend ensures that only authenticated users can access sensitive resources. No more worrying about someone just typing a URL and getting straight into private pages!
This tutorial will walk you through the end-to-end setup based on my recent journey, including the little stumbling blocks I hit along the way.
HOW TO BUILD AN AUTHENTICATION SYSTEM, USING FIREBASE AUTH
Prerequisites
Before we dive in, you'll need a few things set up:
A Firebase project and web app registered in the Firebase Console. Make sure you've enabled Email/Password sign-in.
Firebase SDK installed in your frontend project (
npm install firebase
or included via script tags).Firebase Admin SDK installed in your backend project (
npm install firebase-admin
).An Express.js backend setup with
express
installed.You'll also need
cookie-parser
(npm install cookie-parser
) anddotenv
(npm install dotenv
) installed in your backend.cookie-parser
is crucial for reading cookies sent by the browser, anddotenv
is good practice for managing environment variables like your port.
Step 1: The Frontend - User Login and Sending the Token
This is where the user interacts with your application to sign in. We use the Firebase client SDK to handle the actual authentication process.
First, make sure you've initialized Firebase in your frontend, typically in a separate file (like firebase.js
or in the script for your login page).
// login.js - or your main frontend script
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.4.0/firebase-app.js";
import {
getAuth,
signInWithEmailAndPassword,
} from "https://www.gstatic.com/firebasejs/11.4.0/firebase-auth.js"; //
// Your Firebase configuration details
const firebaseConfig = {
apiKey: "YOUR_API_KEY",
authDomain: "YOUR_PROJECT_ID.firebaseapp.com",
projectId: "YOUR_PROJECT_ID",
storageBucket: "YOUR_PROJECT_ID.appspot.com",
messagingSenderId: "SENDER_ID",
appId: "APP_ID",
};
// Initialize Firebase
const app = initializeApp(firebaseConfig);
const auth = getAuth(app); // Get the Auth service instance
Next, add the code to handle the login button click. This is where the magic happens: we authenticate the user and then get their ID Token. This token is our proof that Firebase verified the user. We then send this token to our backend to establish a secure session there.
// login.js (continued)
document.getElementById("login").addEventListener("click", async (e) => {
e.preventDefault(); // Stop the form from submitting the traditional way
let email = document.getElementById("mail").value;
let password = document.getElementById("pass").value; // Get email and password from input fields
try {
// Authenticate with Firebase
let userCredential = await signInWithEmailAndPassword(auth, email, password);
let user = userCredential.user; // Get the user object
console.log("Signed in as:", user);
// Optional: Check if email is verified
if (!user.emailVerified) {
alert("Please verify your email.We've sent you a link.");
return;
}
// Securely get Firebase ID Token
// This is a short-lived JWT (~1 hour) that proves user's identity
const token = await user.getIdToken();
console.log("~~~~~~~>", token);
// Send ID token to backend to create a session cookie
const response = await fetch('/sessionLogin', {
method: 'POST',
headers: {
'Content-Type': 'application/json' // Tell backend we're sending JSON
},
body: JSON.stringify({ token }), // Send the ID token in the request body
credentials: 'include' // ⭐ CRUCIAL: This tells the browser to send and accept cookies for this origin
});
if (response.ok) {
// Backend successfully verified token and set session cookie
// Now, redirect the user to the protected home page
window.location.href = '/home';
} else {
// Backend rejected the token or failed to set the cookie
alert('Login failed on server.');
}
} catch (error) {
const errorMessage = error.message;
console.log("ERROR::::--->", errorMessage);
if (errorMessage.includes("auth/invalid-credential")) {
alert("Invalid user creadentials"); // Handle specific Firebase errors
} else {
alert("Login failed: " + errorMessage);
}
}
});
Explanation:
We listen for the login button click and prevent the default form submission.
We get the email and password the user entered.
signInWithEmailAndPassword
: This is a Firebase SDK function that sends the user's credentials to Firebase for verification.If successful, we get a
userCredential
object and extract theuser
.user.getIdToken()
: This is the key step! It fetches a fresh Firebase ID Token. This is a JWT (JSON Web Token) that is signed by Firebase and contains information about the authenticated user (like their UID and email). It's valid for about 1 hour.We then use
fetch
to send thistoken
to a backend endpoint we'll create,/sessionLogin
, using a POST request with a JSON body{ token: '...' }
.credentials: 'include'
: This is vital for our cookie strategy. It ensures that if the backend responds with aSet-Cookie
header (which it will), the browser accepts and stores the cookie. It also ensures the browser sends existing cookies for this origin with this request.If the
fetch
request to our backend succeeds (response.ok
), it means the backend successfully processed the token and set the session cookie. We can then safely redirect the user to the home page.
What I Learned: I initially tried redirecting immediately after getting the token, without the backend step, but quickly realised this was insecure. The backend must verify the token. Also, I learned the hard way that window.location.href
doesn't send custom headers, which is why just redirecting wasn't enough when my backend expected a token in the Authorization
header. The cookie method fixes this!
Step 2: The Backend - Creating the Session Cookie
Now that the frontend sends the ID token, our backend needs to receive it, verify it using the Firebase Admin SDK, and then create a session cookie. This cookie will be the basis of our user's session on the backend.
In your main server file (e.g., app.js
), you need to set up Express and the necessary middleware before defining your routes:
// app.js
import express from "express";
import cookieParser from "cookie-parser"; // Import cookie-parser
import path from "path";
import "dotenv/config"; // For process.env.PORT or other env variables
import { fileURLToPath } from "url";
// Import your Firebase Admin config (ensure this initialises admin correctly)
import admin from "./firebaseAdmin.js";
// Boilerplate for __dirname with ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
// --- Middleware setup ---
// ⭐ CRUCIAL: This middleware parses incoming JSON request bodies
app.use(express.json());
// ⭐ CRUCIAL: This middleware parses cookies sent by the browser and populates req.cookies
app.use(cookieParser());
// Serve static files from the 'public/pages' directory
app.use(express.static(path.join(__dirname, "../public/pages")));
// --- Import your authentication middleware (we'll define this next) ---
import authenticateUser from "../public/middlewares/auth.js"; //
// ... socket.io setup (optional, as per your source)
// --- Define your /sessionLogin route ---
app.post('/sessionLogin', async (req, res) => {
// ⭐ Problem I hit: TypeError: Cannot read properties of undefined (reading 'token')
// ⭐ Solution: I forgot app.use(express.json())!
console.log("}}---->", req.body); // This should now show the JSON body { token: '...' }
const idToken = req.body.token; // Get the ID token sent from the frontend
// Define how long the session cookie should last
// This is independent of the ID token's 1-hour expiry
const expiresIn = 60 * 60 * 1000; // 1 hour in milliseconds (example)
try {
// Verify the Firebase ID token and create a session cookie
// The Admin SDK handles this conversion securely
const sessionCookie = await admin.auth().createSessionCookie(idToken, { expiresIn });
// Set the session cookie in the user's browser
const options = {
maxAge: expiresIn, // Cookie expiry time matches the session expiry
httpOnly: true, // ⭐ IMPORTANT: Cookie cannot be accessed by client-side JavaScript - protects against XSS
secure: process.env.NODE_ENV === 'production', // ⭐ IMPORTANT: Only send over HTTPS in production
sameSite: 'Strict', // ⭐ IMPORTANT: Mitigates CSRF attacks
};
res.cookie('session', sessionCookie, options); // 'session' is the name of our cookie
// Send a success response back to the frontend
res.status(200).send({ status: 'success' });
} catch (error) {
// If token is invalid or creation fails, send unauthorized
console.error('Error creating session cookie:', error);
res.status(401).send('Unauthorized');
}
});
// ... other routes (like /signup, /login)
const PORT = process.env.PORT || 8000; // Get port from environment or default
server.listen(PORT, () => { // Note: Listen on the http server instance for socket.io
console.log("listening to PORT", PORT);
});
Explanation:
app.use(express.json())
: This middleware is essential. It intercepts incoming requests withContent-Type: application/json
and automatically parses the JSON body, making it available onreq.body
. Without it,req.body
would beundefined
, leading to theTypeError
I experienced.app.use(cookieParser())
: This middleware parses theCookie
header from incoming requests and populatesreq.cookies
with an object where keys are cookie names (like 'session') and values are cookie values. This preventsTypeError: Cannot read properties of undefined (reading 'session')
later on./sessionLogin
route: This route receives the POST request from the frontend.req.body.token
: We safely access the ID token from the parsed request body.admin.auth().createSessionCookie(idToken, { expiresIn })
: This is the core backend Firebase Admin function. It takes the verified Firebase ID token (which is implicit in this function call, Admin SDK knows how to handle it) and converts it into a session cookie value. You specify the desired lifetime for this session, which can be much longer than the 1-hour ID token expiry (up to 2 weeks).res.cookie('session', sessionCookie, options)
: This sets the cookie named 'session' in the user's browser with the value generated by Firebase Admin SDK. Theoptions
object is absolutely critical for security:httpOnly: true
makes the cookie inaccessible to client-side JavaScript (document.cookie
will not show it). This is a major protection against Cross-Site Scripting (XSS) attacks where malicious JS could steal credentials.secure: true
ensures the cookie is only sent over HTTPS connections. Essential for production.sameSite: 'Strict'
(or 'Lax') provides protection against Cross-Site Request Forgery (CSRF) attacks by controlling when the browser sends the cookie with cross-site requests.maxAge
sets the browser-side expiry for the cookie, matching theexpiresIn
we set for the session.
If the cookie is set successfully, we send a 200 response, prompting the frontend to redirect.
What I Learned: The biggest hurdle here was understanding middleware. Express doesn't handle JSON bodies or cookies out of the box – you need express.json()
and cookie-parser
, and they must be used with app.use()
before any routes that need them. And those cookie options? Non-negotiable for real-world security.
Step 3: The Backend Middleware - Protecting Routes
Now that we have a secure session cookie, we need a way to check for it on every request to a protected page (like /home
). This is the job of our middleware.
// public/middlewares/auth.js
import admin from "../../src/firebaseAdmin.js"; // Ensure this path is correct relative to your project structure
const authenticateUser = async (req, res, next) => {
// ⭐ Problem I hit: TypeError: Cannot read properties of undefined (reading 'session')
// ⭐ Solution: I forgot app.use(cookieParser())!
const sessionCookie = req.cookies.session; // Read the 'session' cookie from the request
// If no session cookie is found, the user is not logged in
if (!sessionCookie) {
console.log('No session cookie'); // Debug log
// Respond with Unauthorized status
return res.status(401).send('Unauthorized: No session cookie');
}
try {
// Verify the session cookie using Firebase Admin SDK
// The second argument 'true' ensures it checks for revocation
const decodedClaims = await admin.auth().verifySessionCookie(sessionCookie, true);
// If verification succeeds, attach the decoded user claims to the request object
// This makes user info (UID, email, etc.) available in the route handler
req.user = decodedClaims;
console.log("--------------------------DONE GOOD--------------------"); // Debug log
// Call next() to allow the request to proceed to the actual route handler
next();
} catch (err) {
// If verification fails (invalid, expired, or revoked cookie)
console.error('Invalid session cookie', err); // Log the error on the server
// Respond with Unauthorized status
res.status(401).send('Unauthorized: Invalid session cookie');
}
};
export default authenticateUser; // Make the middleware available for use
Explanation:
The middleware function
authenticateUser
takesreq
,res
, andnext
.req.cookies.session
: Thanks tocookie-parser
, we can easily access the value of the cookie named 'session' sent by the browser.If
sessionCookie
is not present, it means the user hasn't logged in via our workflow or their cookie was cleared. We send a 401 Unauthorized response andreturn
to stop further processing.admin.auth().verifySessionCookie(sessionCookie, true)
: This Firebase Admin SDK function is the core check. It takes the cookie value and verifies it against Firebase. It ensures the cookie hasn't been tampered with and hasn't expired. Thetrue
argument is important – it tells Firebase to also check if the session has been explicitly revoked (e.g., if the user changed their password or logged out from all devices).If
verifySessionCookie
succeeds, it returns the claims (user info) decoded from the session cookie. We attach these toreq.user
so our route handler knows who the user is.next()
: This function call is what allows the request to continue to the next function in the Express route chain – which, in our case, will be the actual handler that serves the/home
page.If
verifySessionCookie
throws an error (invalid, expired, or revoked cookie), we catch it, log it, send a 401 Unauthorized response, andreturn
.
What I Learned: Middleware is a function that sits between the request and the final route handler. It can inspect the request, modify it (like adding req.user
), or terminate it by sending a response (like 401). Crucially, it calls next()
to pass control to the next thing in line. I also learned that cookies are automatically sent by the browser with relevant requests once they are set, thanks to the credentials: 'include'
on the frontend fetch
and the correct backend Set-Cookie
header.
Step 4: The Backend - Defining Protected Routes
Now we can simply apply our authenticateUser
middleware to any route we want to protect.
// app.js (continued)
// Use the authentication middleware before the handler for /home
app.get("/home", authenticateUser, (req, res) => {
// This code will ONLY run if authenticateUser calls next()
// The user is authenticated!
console.log("-----------CAME HERE ALSO-------------------") // Debug log
res.sendFile(path.join(__dirname, "../public/pages", "home.html")); // Serve the protected page
});
// You can protect other routes too:
app.get("/map", authenticateUser, (req, res) => { //
// This route is also protected by the middleware
res.sendFile(path.join(__dirname, "../public/pages", "map.html"));
});
// Public routes that don't need authentication
app.get("/signup", (req, res) => {
res.sendFile(path.join(__dirname, "../public/pages", "signup.html"));
});
app.get("/login", (req, res) => {
res.sendFile(path.join(__dirname, "../public/pages", "login.html"));
});
Explanation:
When a request comes in for /home
(or /map
), Express first runs the authenticateUser
middleware.
If the middleware finds a valid session cookie, it calls
next()
, and the request proceeds to the(req, res) => { ... }
function, which serves thehome.html
file.If the middleware doesn't find a valid session cookie (missing, expired, invalid), it sends a 401 response and does not call
next()
. The(req, res) => { ... }
function is never reached, and the user doesn't see the protected content.
What I Learned: This makes protecting routes incredibly clean. You define the protection logic once in the middleware, and then you just add that middleware function name before the route handler function for any route you want to secure.
Understanding Session Expiry
With this setup, the session cookie we created in Step 2 is what dictates the user's session on the backend.
The cookie is set to expire after the
expiresIn
duration you specified (e.g., 1 hour).When the cookie expires, the browser automatically stops sending it with requests.
The next time the user tries to access a protected route (like refreshing the page, clicking a link to
/home
, or making an authenticated API call), theauthenticateUser
middleware won't find the 'session' cookie.It will respond with a 401 Unauthorized status.
The user is NOT immediately kicked out the moment the cookie expires if they are just sitting on the page. They are only "kicked out" (denied access) on the next request they make to the server after the cookie has expired.
What I Learned: Session expiration is checked by the server on each incoming request. The browser doesn't notify your JavaScript when a cookie expires. So the frontend state might look like the user is still logged in until they try to interact with the backend again. Implementing "kick out upon expiry" requires additional frontend logic like polling the server or using timers based on the known expiry time, but for a basic setup, relying on the server rejecting the next request is standard.
Tying It Together: Escaping the "Loop" and Why Cookies Won
I initially thought my problem was that the middleware verifying the token somehow called the /home
route again, creating a loop. That wasn't it. The next()
function just moves to the next handler in the chain, not back to the start.
The real problem was my frontend logic after getting the ID token:
I would get the token from Firebase.
I would then use
fetch('/home', { headers: { Authorization: 'Bearer ' + token } })
to test if the token was valid.If that
fetch
call succeeded, I would usewindow.location.href = '/home'
to redirect the user.
The issue? window.location.href
does NOT send the Authorization
header that my backend middleware was expecting. So, the first request (the fetch
) worked and showed the token in the backend logs (===> Bearer ...
), but the second request (the window.location.href
redirect) didn't send the header, causing the backend middleware to see req.headers.authorization
as undefined
and respond with "Unauthorized: No token provided".
Cookies solved this because once the backend sets a secure HTTP-only cookie, the browser automatically includes that cookie in all subsequent requests to that domain (assuming credentials: 'include'
was used for the initial request that set the cookie, and the cookie options like secure
and sameSite
are met). My middleware then reads the cookie instead of a header I wasn't reliably sending.
This cookie-based flow:
Automatically handles session state across page loads and navigations.
Protects the token from client-side JavaScript access (
httpOnly
).Provides CSRF mitigation (
sameSite
).Feels like a traditional web session, which is often more intuitive for multi-page applications.
It's definitely the more robust and secure way to handle sessions for this type of application compared to just managing tokens manually on the frontend or trying to pass them via non-standard headers on redirects.
Conclusion
Building a secure authentication system involves more than just letting users log in on the frontend. You must protect your backend resources. By combining Firebase Authentication on the client for the initial login with Firebase Admin SDK on the backend to verify tokens and establish secure session cookies, you create a powerful and safe flow.
The key takeaways are:
Use
user.getIdToken()
on the frontend after Firebase login.Send this token to a backend endpoint (like
/sessionLogin
) via POST, ensuringcredentials: 'include'
is used infetch
options.On the backend, use
admin.auth().createSessionCookie
with the ID token to generate a session cookie value.Set this cookie in the user's browser using
res.cookie
, making sure to use thehttpOnly
,secure
, andsameSite
flags.Use
cookie-parser
middleware in Express to easily read the cookie on subsequent requests.Create authentication middleware that uses
admin.auth().verifySessionCookie
to check the cookie on protected routes.Apply this middleware to any route you want to secure.
Getting this system working correctly took some trial and error, especially understanding how cookies, headers, and middleware interact in Express. But now that it's solid, I feel much more confident in the security of my application. You've got this!
Subscribe to my newsletter
Read articles from Nishant Shukla 014 directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
