Coding my First Authentication system

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:

  1. A Firebase project and web app registered in the Firebase Console. Make sure you've enabled Email/Password sign-in.

  2. Firebase SDK installed in your frontend project (npm install firebase or included via script tags).

  3. Firebase Admin SDK installed in your backend project (npm install firebase-admin).

  4. An Express.js backend setup with express installed.

  5. You'll also need cookie-parser (npm install cookie-parser) and dotenv (npm install dotenv) installed in your backend. cookie-parser is crucial for reading cookies sent by the browser, and dotenv 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:

  1. We listen for the login button click and prevent the default form submission.

  2. We get the email and password the user entered.

  3. signInWithEmailAndPassword: This is a Firebase SDK function that sends the user's credentials to Firebase for verification.

  4. If successful, we get a userCredential object and extract the user.

  5. 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.

  6. We then use fetch to send this token to a backend endpoint we'll create, /sessionLogin, using a POST request with a JSON body { token: '...' }.

  7. credentials: 'include': This is vital for our cookie strategy. It ensures that if the backend responds with a Set-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.

  8. 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!

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:

  1. app.use(express.json()): This middleware is essential. It intercepts incoming requests with Content-Type: application/json and automatically parses the JSON body, making it available on req.body. Without it, req.body would be undefined, leading to the TypeError I experienced.

  2. app.use(cookieParser()): This middleware parses the Cookie header from incoming requests and populates req.cookies with an object where keys are cookie names (like 'session') and values are cookie values. This prevents TypeError: Cannot read properties of undefined (reading 'session') later on.

  3. /sessionLogin route: This route receives the POST request from the frontend.

  4. req.body.token: We safely access the ID token from the parsed request body.

  5. 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).

  6. res.cookie('session', sessionCookie, options): This sets the cookie named 'session' in the user's browser with the value generated by Firebase Admin SDK. The options 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 the expiresIn we set for the session.

  7. 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:

  1. The middleware function authenticateUser takes req, res, and next.

  2. req.cookies.session: Thanks to cookie-parser, we can easily access the value of the cookie named 'session' sent by the browser.

  3. 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 and return to stop further processing.

  4. 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. The true 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).

  5. If verifySessionCookie succeeds, it returns the claims (user info) decoded from the session cookie. We attach these to req.user so our route handler knows who the user is.

  6. 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.

  7. If verifySessionCookie throws an error (invalid, expired, or revoked cookie), we catch it, log it, send a 401 Unauthorized response, and return.

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.

  1. If the middleware finds a valid session cookie, it calls next(), and the request proceeds to the (req, res) => { ... } function, which serves the home.html file.

  2. 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), the authenticateUser 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:

  1. I would get the token from Firebase.

  2. I would then use fetch('/home', { headers: { Authorization: 'Bearer ' + token } }) to test if the token was valid.

  3. If that fetch call succeeded, I would use window.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, ensuring credentials: 'include' is used in fetch 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 the httpOnly, secure, and sameSite 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!


1
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

Nishant Shukla 014
Nishant Shukla 014