Back to blog
nodejsintermediate

Node.js Authentication with JWT

Build a complete authentication system in Express — user registration, login, JWT access tokens, refresh tokens, password hashing, and protected routes.

LearnixoApril 16, 20266 min read
Node.jsJWTAuthenticationbcryptSecurityIntermediate
Share:𝕏

A proper auth system has three parts: secure password storage (bcrypt), short-lived access tokens (JWT), and long-lived refresh tokens. This lesson builds all three from scratch.


Install Dependencies

Bash
npm install bcryptjs jsonwebtoken
npm install -D @types/bcryptjs @types/jsonwebtoken

Password Hashing

Never store plain-text passwords. bcrypt is the standard — it's slow by design (makes brute-forcing expensive):

TYPESCRIPT
import bcrypt from 'bcryptjs';

// Hash on registration
const SALT_ROUNDS = 12;  // higher = slower hash = harder to brute-force
const hashedPassword = await bcrypt.hash(plainPassword, SALT_ROUNDS);
// Store hashedPassword in database

// Compare on login
const isValid = await bcrypt.compare(plainPassword, storedHash);
// Returns true/false

JWT Token Service

TYPESCRIPT
import jwt from 'jsonwebtoken';

export interface TokenPayload {
  userId: number;
  email:  string;
  role:   string;
}

const ACCESS_SECRET  = process.env.JWT_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;

export const tokenService = {
  generateAccessToken(payload: TokenPayload): string {
    return jwt.sign(payload, ACCESS_SECRET, { expiresIn: '15m' });
  },

  generateRefreshToken(payload: TokenPayload): string {
    return jwt.sign(payload, REFRESH_SECRET, { expiresIn: '7d' });
  },

  verifyAccessToken(token: string): TokenPayload {
    return jwt.verify(token, ACCESS_SECRET) as TokenPayload;
  },

  verifyRefreshToken(token: string): TokenPayload {
    return jwt.verify(token, REFRESH_SECRET) as TokenPayload;
  },
};

Access tokens expire in 15 minutes — short enough to limit damage if stolen. Refresh tokens last 7 days and are used to get new access tokens without re-logging in.


Auth Service

TYPESCRIPT
import bcrypt from 'bcryptjs';
import { prisma } from '../lib/prisma';
import { tokenService } from './token.service';
import { AppError } from '../middleware/error.middleware';

export class AuthService {
  async register(email: string, name: string, password: string) {
    const existing = await prisma.user.findUnique({ where: { email } });
    if (existing) throw new AppError(409, 'Email already registered');

    const hashed = await bcrypt.hash(password, 12);
    const user   = await prisma.user.create({
      data: { email, name, password: hashed },
      select: { id: true, email: true, name: true, role: true },
    });

    const accessToken  = tokenService.generateAccessToken({ userId: user.id, email: user.email, role: user.role });
    const refreshToken = tokenService.generateRefreshToken({ userId: user.id, email: user.email, role: user.role });

    // Store refresh token hash in DB (so we can revoke it)
    await prisma.refreshToken.create({
      data: {
        token:   await bcrypt.hash(refreshToken, 10),
        userId:  user.id,
        expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
      },
    });

    return { user, accessToken, refreshToken };
  }

  async login(email: string, password: string) {
    const user = await prisma.user.findUnique({ where: { email } });
    if (!user) throw new AppError(401, 'Invalid credentials');

    const valid = await bcrypt.compare(password, user.password);
    if (!valid) throw new AppError(401, 'Invalid credentials');

    const accessToken  = tokenService.generateAccessToken({ userId: user.id, email: user.email, role: user.role });
    const refreshToken = tokenService.generateRefreshToken({ userId: user.id, email: user.email, role: user.role });

    await prisma.refreshToken.create({
      data: {
        token:     await bcrypt.hash(refreshToken, 10),
        userId:    user.id,
        expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
      },
    });

    return {
      user:   { id: user.id, email: user.email, name: user.name, role: user.role },
      accessToken,
      refreshToken,
    };
  }

  async refresh(refreshToken: string) {
    let payload: { userId: number; email: string; role: string };
    try {
      payload = tokenService.verifyRefreshToken(refreshToken);
    } catch {
      throw new AppError(401, 'Invalid refresh token');
    }

    // Find a stored token that matches
    const storedTokens = await prisma.refreshToken.findMany({
      where: { userId: payload.userId, expiresAt: { gt: new Date() } },
    });

    const valid = await Promise.any(
      storedTokens.map(t => bcrypt.compare(refreshToken, t.token)),
    ).catch(() => false);

    if (!valid) throw new AppError(401, 'Refresh token revoked or not found');

    // Issue new access token
    return {
      accessToken: tokenService.generateAccessToken({
        userId: payload.userId,
        email:  payload.email,
        role:   payload.role,
      }),
    };
  }

  async logout(userId: number): Promise<void> {
    // Delete all refresh tokens for this user
    await prisma.refreshToken.deleteMany({ where: { userId } });
  }
}

Auth Routes

TYPESCRIPT
import { Router } from 'express';
import { AuthService } from '../services/auth.service';
import { validateBody } from '../middleware/validate.middleware';
import { loginSchema, registerSchema } from '../schemas/auth.schema';
import { authenticate } from '../middleware/auth.middleware';

const authService = new AuthService();
export const authRouter = Router();

authRouter.post('/register', validateBody(registerSchema), async (req, res, next) => {
  try {
    const { user, accessToken, refreshToken } = await authService.register(
      req.body.email, req.body.name, req.body.password,
    );

    // Send refresh token as HttpOnly cookie — not accessible to JS
    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure:   process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge:   7 * 24 * 60 * 60 * 1000,   // 7 days
    });

    res.status(201).json({ user, accessToken });
  } catch (err) {
    next(err);
  }
});

authRouter.post('/login', validateBody(loginSchema), async (req, res, next) => {
  try {
    const { user, accessToken, refreshToken } = await authService.login(
      req.body.email, req.body.password,
    );

    res.cookie('refreshToken', refreshToken, {
      httpOnly: true, secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000,
    });

    res.json({ user, accessToken });
  } catch (err) {
    next(err);
  }
});

authRouter.post('/refresh', async (req, res, next) => {
  try {
    const token = req.cookies?.refreshToken;
    if (!token) { res.status(401).json({ error: 'No refresh token' }); return; }

    const { accessToken } = await authService.refresh(token);
    res.json({ accessToken });
  } catch (err) {
    next(err);
  }
});

authRouter.post('/logout', authenticate, async (req, res, next) => {
  try {
    await authService.logout(req.user!.userId);
    res.clearCookie('refreshToken');
    res.status(204).send();
  } catch (err) {
    next(err);
  }
});

// Protected route example
authRouter.get('/me', authenticate, async (req, res, next) => {
  try {
    const user = await prisma.user.findUnique({
      where:  { id: req.user!.userId },
      select: { id: true, email: true, name: true, role: true },
    });
    res.json(user);
  } catch (err) {
    next(err);
  }
});

Role-Based Authorization

TYPESCRIPT
export function authorize(...roles: string[]) {
  return (req: Request, res: Response, next: NextFunction): void => {
    if (!req.user) {
      res.status(401).json({ error: 'Not authenticated' });
      return;
    }
    if (!roles.includes(req.user.role)) {
      res.status(403).json({ error: 'Insufficient permissions' });
      return;
    }
    next();
  };
}

// Usage
adminRouter.delete('/users/:id', authenticate, authorize('ADMIN'), deleteUser);

Cookie vs localStorage for Tokens

| | HttpOnly Cookie | localStorage | |---|---|---| | XSS access | No (JS can't read it) | Yes (any JS can read it) | | CSRF risk | Yes (auto-sent by browser) | No | | Recommendation | Refresh tokens | Access tokens |

Best practice: store the short-lived access token in memory (JS variable), refresh token in an HttpOnly cookie. The attacker who steals an access token has 15 minutes of access. They can't steal the refresh token via XSS.


Validation Schemas

TYPESCRIPT
import { z } from 'zod';

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

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

Quick Reference

Hash password:   bcrypt.hash(plain, 12)
Compare:         bcrypt.compare(plain, hash) → boolean
Access token:    jwt.sign(payload, secret, { expiresIn: '15m' })
Refresh token:   jwt.sign(payload, refreshSecret, { expiresIn: '7d' })
Verify:          jwt.verify(token, secret) → payload or throws
Refresh flow:    verify refresh token → issue new access token
Revocation:      store refresh token hash in DB, delete on logout
Cookie:          res.cookie('refreshToken', token, { httpOnly: true })
Auth middleware: req.user = jwt.verify(bearerToken, secret)
Authorization:   check req.user.role against allowed roles

Enjoyed this article?

Explore the learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.