Building Secure Session-Based Authentication from Scratch
Last updated on

Building Secure Session-Based Authentication from Scratch

By

Profile

D3OXY

Session-based authentication remains one of the most reliable and secure methods for managing user authentication in web applications. In this comprehensive guide, we’ll build a secure session-based authentication system from scratch using TypeScript, Express, and Redis.

Why Session-Based Authentication?

Before diving into the implementation, let’s understand why session-based authentication is often the better choice:

  1. Better Security: Easy to invalidate sessions immediately
  2. Smaller Payload: Only sends a session ID in cookies
  3. Simpler Implementation: Most frameworks have built-in support
  4. Better Control: Full control over session lifecycle

Project Setup

First, let’s set up our project with the necessary dependencies:

# Initialize project
pnpm init

# Install dependencies
pnpm add express express-session connect-redis redis
pnpm add @types/express @types/express-session @types/connect-redis
pnpm add bcryptjs zod argon2 cors helmet
pnpm add -D typescript @types/node ts-node-dev

Create a tsconfig.json:

{
    "compilerOptions": {
        "target": "ES2020",
        "module": "CommonJS",
        "lib": ["ES2020"],
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "outDir": "./dist",
        "rootDir": "./src"
    },
    "include": ["src/**/*"],
    "exclude": ["node_modules"]
}

Core Implementation

1. Setting Up Redis Store

// src/lib/redis.ts
import { createClient } from "redis";

export const redis = createClient({
    url: process.env.REDIS_URL ?? "redis://localhost:6379",
});

redis.on("error", (err) => console.error("Redis Client Error", err));
redis.on("connect", () => console.log("Redis Client Connected"));

await redis.connect();

2. Session Configuration

// src/config/session.ts
import session from "express-session";
import connectRedis from "connect-redis";
import { redis } from "../lib/redis";

const RedisStore = connectRedis(session);

export const sessionConfig = {
    store: new RedisStore({ client: redis }),
    secret: process.env.SESSION_SECRET ?? "super-secret-key",
    name: "sid",
    resave: false,
    saveUninitialized: false,
    cookie: {
        secure: process.env.NODE_ENV === "production",
        httpOnly: true,
        /**
         * Lax is usually a good balance, but feel free to tighten to "strict"
         * if your app does not rely on cross-site GET navigation.
         */
        sameSite: "lax" as const,
        maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
    },
};

3. User Schema and Validation

// src/schemas/user.schema.ts
import { z } from "zod";

export const registerSchema = z.object({
    email: z.string().email(),
    password: z.string().min(8),
    name: z.string().min(2),
});

export const loginSchema = z.object({
    email: z.string().email(),
    password: z.string(),
});

export type RegisterInput = z.infer<typeof registerSchema>;
export type LoginInput = z.infer<typeof loginSchema>;

4. Authentication Middleware

// src/middleware/auth.ts
import { Request, Response, NextFunction } from "express";

export const isAuthenticated = (req: Request, res: Response, next: NextFunction) => {
    if (!req.session.userId) {
        return res.status(401).json({ error: "Unauthorized" });
    }
    next();
};

export const isGuest = (req: Request, res: Response, next: NextFunction) => {
    if (req.session.userId) {
        return res.status(400).json({ error: "Already authenticated" });
    }
    next();
};

5. Authentication Routes

// src/routes/auth.routes.ts
import { Router } from "express";
import * as argon2 from "argon2";
import { loginSchema, registerSchema } from "../schemas/user.schema";
import { isAuthenticated, isGuest } from "../middleware/auth";
import { prisma } from "../lib/prisma";

const router = Router();

router.post("/register", isGuest, async (req, res) => {
    try {
        const data = registerSchema.parse(req.body);

        const existingUser = await prisma.user.findUnique({
            where: { email: data.email },
        });

        if (existingUser) {
            return res.status(400).json({ error: "Email already registered" });
        }

        const hashedPassword = await argon2.hash(data.password);

        const user = await prisma.user.create({
            data: {
                email: data.email,
                password: hashedPassword,
                name: data.name,
            },
        });

        req.session.userId = user.id;

        return res.status(201).json({
            user: {
                id: user.id,
                email: user.email,
                name: user.name,
            },
        });
    } catch (error) {
        return res.status(400).json({ error: "Invalid input" });
    }
});

router.post("/login", isGuest, async (req, res) => {
    try {
        const data = loginSchema.parse(req.body);

        const user = await prisma.user.findUnique({
            where: { email: data.email },
        });

        if (!user) {
            return res.status(400).json({ error: "Invalid credentials" });
        }

        const validPassword = await argon2.verify(user.password, data.password);

        if (!validPassword) {
            return res.status(400).json({ error: "Invalid credentials" });
        }

        req.session.userId = user.id;

        return res.json({
            user: {
                id: user.id,
                email: user.email,
                name: user.name,
            },
        });
    } catch (error) {
        return res.status(400).json({ error: "Invalid input" });
    }
});

router.post("/logout", isAuthenticated, (req, res) => {
    req.session.destroy((err) => {
        if (err) {
            return res.status(500).json({ error: "Could not logout" });
        }
        res.clearCookie("sid");
        return res.json({ message: "Logged out successfully" });
    });
});

export default router;

Security Best Practices

  1. Password Hashing: Use Argon2 instead of bcrypt
// Better password hashing with Argon2
const hashedPassword = await argon2.hash(password, {
    type: argon2.argon2id,
    memoryCost: 2 ** 16,
    timeCost: 3,
});
  1. Rate Limiting:
import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts
  message: { error: 'Too many login attempts. Please try again later.' }
});

router.post('/login', loginLimiter, ...);
  1. CSRF Protection:
import csurf from "csurf";

app.use(
    csurf({
        cookie: {
            httpOnly: true,
            sameSite: "lax",
            secure: process.env.NODE_ENV === "production",
        },
    })
);
  1. Security Headers:
import helmet from "helmet";

app.use(helmet());

Tips and Tricks

  1. Session Regeneration: Regenerate session ID after login
req.session.regenerate((err) => {
    if (err) return res.status(500).json({ error: "Session error" });
    req.session.userId = user.id;
});
  1. Multiple Devices: Track active sessions
// In Redis store
await redis.sAdd(`user:${userId}:sessions`, sessionId);
  1. Remember Me: Extend session duration conditionally
if (req.body.rememberMe) {
    req.session.cookie.maxAge = 1000 * 60 * 60 * 24 * 30; // 30 days
}

Alternatives and Extensions

  1. OAuth Integration: Add social login support
  2. Two-Factor Authentication: Implement 2FA using authenticator apps
  3. Session Management UI: Allow users to view and manage active sessions
  4. Passwordless Auth: Email magic links as an alternative

Source Code

The complete source code for this implementation is available on GitHub: Session-Based Auth Template

Acknowledgments

Special thanks to:

Conclusion

Session-based authentication provides a robust, secure, and scalable solution for web applications. By following these best practices and implementing proper security measures, you can build a reliable authentication system that protects your users’ data while maintaining a great user experience.

Remember to:

The code examples provided are production-ready but should be adapted to your specific needs and security requirements.