Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Authentication with JWT

Most web applications need to know who is making requests. Think of it like a building with a security desk: authentication is showing your ID badge to prove you are who you claim to be, while authorization is checking whether your badge grants access to the floor you are trying to reach. In this chapter, we’ll implement a complete authentication system using industry-standard practices.

Authentication vs. Authorization

ConceptQuestion It AnswersExample
Authentication”Who are you?”Login with username/password
Authorization”What can you do?”Can this user delete posts?

Understanding JWT (JSON Web Tokens)

Traditional session-based authentication stores user data on the server. This works, but has challenges:
  • Scalability: Sessions must be shared across multiple servers
  • Mobile apps: Cookies don’t work well on native apps
  • Microservices: Each service needs access to session store
JWT solves these problems with stateless authentication. Instead of storing session data on the server, the server issues a signed token that the client stores and sends with each request. Think of it like a concert wristband: the venue stamps it once at the entrance, and then every stage and bar inside just glances at your wrist instead of checking the guest list again.

How JWT Works

1. User logs in with credentials
2. Server verifies credentials
3. Server creates a signed JWT containing user info
4. Client stores the token (localStorage, cookie)
5. Client sends token with every request
6. Server verifies token signature and extracts user info
The critical insight: the server never stores the token. It only needs the secret key to verify the signature. This is what makes JWT “stateless” — the token itself carries all the information needed to authenticate the request.

JWT Structure

A JWT consists of three parts separated by dots:
header.payload.signature
  • Header: Algorithm and token type
  • Payload: User data (claims) like user ID, role, expiration
  • Signature: Ensures the token hasn’t been tampered with
Never store sensitive information (passwords, credit cards) in the JWT payload. The payload is Base64-encoded, not encrypted—anyone can decode and read it.

Prerequisites

Install the necessary packages:
npm install jsonwebtoken bcryptjs
  • jsonwebtoken: To sign and verify tokens.
  • bcryptjs: To hash passwords securely.

1. User Registration (Hashing Passwords)

Never store passwords in plain text. If your database is compromised, every user’s password is exposed. Instead, use bcryptjs to hash them — a one-way transformation that turns "myPassword123" into an irreversible string like "$2a$10$N9qo8uLOi...". Even if an attacker steals the hashes, they cannot reverse them back into passwords. Bcrypt also adds a random salt to each hash, so two users with the same password get different hashes.
const bcrypt = require('bcryptjs');

// ... inside your register route
const { username, password } = req.body;

// Check if user exists...

// Generate a salt with cost factor 10 (2^10 = 1024 rounds of hashing).
// Higher cost = slower hashing = harder to brute-force, but also slower registration.
// 10 is a good default; bump to 12 for high-security apps.
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);

// Save user with hashedPassword...
// The original plaintext password should NEVER be stored or logged.
Why bcrypt over SHA-256 or MD5? General-purpose hash functions like SHA-256 are designed to be fast. That is a liability for password hashing — attackers can try billions of SHA-256 guesses per second. Bcrypt is intentionally slow and configurable, making brute-force attacks impractical. A cost factor of 10 takes roughly 50-100ms per hash, which is imperceptible to a user logging in but devastating to an attacker trying millions of passwords.

2. User Login (Comparing Passwords)

When a user logs in, compare the provided password with the hashed password in the database. You cannot “un-hash” the stored password to compare — instead, bcrypt hashes the incoming password with the same salt and checks if the result matches.
// bcrypt.compare() hashes the plaintext password using the salt embedded
// in user.password, then compares the two hashes. Returns true or false.
const validPassword = await bcrypt.compare(password, user.password);

if (!validPassword) {
  // Security best practice: use the same error message for "user not found"
  // and "wrong password." If you say "wrong password," an attacker learns
  // that the email exists -- that is information leakage.
  return res.status(400).json({ msg: 'Invalid credentials' });
}

3. Generating a JWT

If the password is valid, generate a token and send it to the client. The token is a signed claim that says “this request is from user X, and I (the server) vouch for it.”
const jwt = require('jsonwebtoken');

// The payload contains claims -- data about the user that will be
// embedded in the token. Keep it minimal: only the user ID and role.
// Every byte in the payload is sent with EVERY request as an HTTP header.
const payload = {
  user: {
    id: user.id
  }
};

// jwt.sign() creates the three-part token (header.payload.signature).
// The secret key is used to create the signature -- anyone with the key
// can create valid tokens, so guard it carefully.
jwt.sign(
  payload,
  process.env.JWT_SECRET,
  { expiresIn: '1h' },  // Token becomes invalid after 1 hour
  (err, token) => {
    if (err) throw err;
    res.json({ token });
  }
);
Store JWT_SECRET in your .env file. Use a long, random string (at least 256 bits / 32 characters). Generate one with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))". Never reuse secrets across environments — your development and production secrets should be completely different values.

4. Protecting Routes (Middleware)

Create middleware to verify the token on protected routes. This middleware acts as a gatekeeper — like the bouncer at a club who checks wristbands before letting anyone into the VIP area. Every request to a protected endpoint must pass through this function first. middleware/auth.js
const jwt = require('jsonwebtoken');

module.exports = function(req, res, next) {
  // Get token from header -- the client sends it as 'x-auth-token'.
  // In production, many teams prefer the 'Authorization: Bearer <token>' header instead.
  const token = req.header('x-auth-token');

  // No token means the user is not logged in (or forgot to send it)
  if (!token) {
    return res.status(401).json({ msg: 'No token, authorization denied' });
  }

  // jwt.verify() does two things:
  // 1. Checks the signature to ensure the token was not tampered with
  // 2. Checks the expiration date to ensure the token is still valid
  // If either check fails, it throws an error.
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    // Attach the user payload to the request object so downstream
    // route handlers can access req.user.id without re-parsing the token
    req.user = decoded.user;
    next();
  } catch (err) {
    // This catches both expired tokens (TokenExpiredError) and
    // tampered tokens (JsonWebTokenError). Do not distinguish between
    // them in the response -- that would leak information to attackers.
    res.status(401).json({ msg: 'Token is not valid' });
  }
};

5. Using the Middleware

Apply the auth middleware to any route that requires a logged-in user. Express middleware executes in order, so auth runs before your route handler — if the token is invalid, the request never reaches your handler.
const auth = require('./middleware/auth');

// Protected route -- 'auth' runs first, then the async handler.
// By the time the handler executes, req.user is guaranteed to be populated.
app.get('/api/auth/user', auth, async (req, res) => {
  try {
    // req.user.id was set by the auth middleware after verifying the token
    const user = await User.findById(req.user.id).select('-password');
    // .select('-password') excludes the password hash from the response --
    // never send password hashes to the client, even to the authenticated user
    res.json(user);
  } catch (err) {
    res.status(500).send('Server Error');
  }
});

Summary

  • bcryptjs: Use hash() to encrypt passwords and compare() to validate
  • JWT: Use sign() to create tokens and verify() to validate
  • Middleware: Create custom middleware to protect private routes
  • Stateless: The server doesn’t store session data; the token contains necessary info

Access Tokens vs Refresh Tokens

A single long-lived token is a security risk: if stolen, an attacker has access for the entire token lifetime. A single short-lived token is a usability nightmare: users have to log in every 15 minutes. The solution is a dual-token system that gives you the best of both worlds — like a day pass (access token) and a membership card (refresh token). The day pass expires quickly but can be renewed by showing the membership card. For production applications, implement this dual-token system:
Token TypeLifetimePurpose
Access Token15 min - 1 hourAuthenticate API requests
Refresh Token7-30 daysGet new access tokens
// Generate both tokens -- note the different secrets and lifetimes.
// Using separate secrets means a stolen access token cannot be used
// as a refresh token (and vice versa).
const generateTokens = (userId) => {
  const accessToken = jwt.sign(
    { userId },
    process.env.ACCESS_TOKEN_SECRET,
    { expiresIn: '15m' }  // Short-lived: limits damage if stolen
  );
  
  const refreshToken = jwt.sign(
    { userId },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' }   // Long-lived: stored securely in httpOnly cookie
  );
  
  return { accessToken, refreshToken };
};

// Login endpoint
app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;
  
  const user = await User.findOne({ email });
  if (!user || !await bcrypt.compare(password, user.password)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  const { accessToken, refreshToken } = generateTokens(user._id);
  
  // Store refresh token in database or Redis so we can revoke it on logout.
  // Without server-side storage, there is no way to invalidate a refresh token
  // before it expires -- a stolen token remains valid indefinitely.
  await RefreshToken.create({ token: refreshToken, userId: user._id });
  
  // Send refresh token as httpOnly cookie -- this is critical:
  // httpOnly: JavaScript cannot read it (prevents XSS theft)
  // secure: only sent over HTTPS (prevents man-in-the-middle)
  // sameSite: only sent to same origin (prevents CSRF)
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
  });
  
  res.json({ accessToken });
});

// Refresh endpoint
app.post('/api/auth/refresh', async (req, res) => {
  const { refreshToken } = req.cookies;
  
  if (!refreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }
  
  try {
    const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
    
    // Check if refresh token exists in database
    const storedToken = await RefreshToken.findOne({ token: refreshToken });
    if (!storedToken) {
      return res.status(401).json({ error: 'Invalid refresh token' });
    }
    
    // Generate new access token
    const accessToken = jwt.sign(
      { userId: decoded.userId },
      process.env.ACCESS_TOKEN_SECRET,
      { expiresIn: '15m' }
    );
    
    res.json({ accessToken });
  } catch (error) {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
});

// Logout endpoint
app.post('/api/auth/logout', async (req, res) => {
  const { refreshToken } = req.cookies;
  
  // Remove from database
  await RefreshToken.deleteOne({ token: refreshToken });
  
  // Clear cookie
  res.clearCookie('refreshToken');
  res.json({ message: 'Logged out successfully' });
});

Role-Based Access Control (RBAC)

RBAC restricts system access based on the roles assigned to users. Think of it like different colored badges in a hospital: doctors (admin) can access patient records and prescribe medication, nurses (moderator) can view records and update notes, and visitors (user) can only access the waiting area. The system checks your badge color, not your name, to determine what you can do.
// User model with roles
const UserSchema = new mongoose.Schema({
  email: String,
  password: String,
  role: {
    type: String,
    enum: ['user', 'admin', 'moderator'],
    default: 'user'
  }
});

// Role middleware -- a higher-order function that returns middleware.
// The ...roles spread operator lets you pass multiple allowed roles.
const authorize = (...roles) => {
  return (req, res, next) => {
    // 403 Forbidden (not 401 Unauthorized) -- the user IS authenticated
    // but does not have sufficient privileges. This distinction matters.
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        error: 'Not authorized to access this resource'
      });
    }
    next();
  };
};

// Usage -- note the middleware chain: auth first (who are you?),
// then authorize (are you allowed?). Order matters.
app.get('/api/admin/users', auth, authorize('admin'), (req, res) => {
  // Only admins can access
});

app.delete('/api/posts/:id', auth, authorize('admin', 'moderator'), (req, res) => {
  // Admins and moderators can delete
});

OAuth 2.0 with Passport.js

OAuth lets users log in using their existing accounts (Google, GitHub, Facebook) instead of creating yet another username and password. From the user’s perspective, they click “Sign in with Google,” authorize your app, and land on your site — no new password to remember. From your perspective, you delegate the hard parts of authentication (password storage, account recovery, two-factor auth) to Google, and you receive a verified profile in return. Passport.js is the de facto authentication middleware for Node.js. It uses a plugin architecture called “strategies” — each strategy handles a different authentication method (local password, Google OAuth, GitHub OAuth, etc.).
npm install passport passport-google-oauth20
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

// The Google strategy handles the OAuth 2.0 dance:
// 1. Your app redirects the user to Google's login page
// 2. User authorizes your app
// 3. Google redirects back to your callbackURL with a temporary code
// 4. Passport exchanges that code for the user's profile (this callback fires)
passport.use(new GoogleStrategy({
    clientID: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    callbackURL: '/auth/google/callback'
  },
  async (accessToken, refreshToken, profile, done) => {
    try {
      // Check if this Google account is already linked to a user in our DB
      let user = await User.findOne({ googleId: profile.id });
      
      if (!user) {
        // First time logging in with Google -- create a new user record.
        // No password is stored because Google handles authentication.
        user = await User.create({
          googleId: profile.id,
          email: profile.emails[0].value,
          name: profile.displayName,
          avatar: profile.photos[0].value
        });
      }
      
      // done(error, user) -- pass null for error and the user object on success
      return done(null, user);
    } catch (error) {
      return done(error, null);
    }
  }
));

// Routes
app.get('/auth/google',
  passport.authenticate('google', { scope: ['profile', 'email'] })
);

app.get('/auth/google/callback',
  passport.authenticate('google', { session: false }),
  (req, res) => {
    const { accessToken, refreshToken } = generateTokens(req.user._id);
    res.redirect(`/auth-success?token=${accessToken}`);
  }
);

Password Reset Flow

Password reset is one of the most security-sensitive flows in any application. The pattern works like this: the user requests a reset, you generate a random token, email it as a link, and when they click the link, you verify the token and let them set a new password. The key security properties are: the token is single-use, time-limited, and the plaintext token is never stored in your database (only its hash).
const crypto = require('crypto');
const nodemailer = require('nodemailer');

// Request password reset
app.post('/api/auth/forgot-password', async (req, res) => {
  const user = await User.findOne({ email: req.body.email });
  
  if (!user) {
    // CRITICAL: Return the same response whether or not the email exists.
    // Saying "email not found" tells attackers which emails are registered.
    return res.json({ message: 'If email exists, reset link sent' });
  }
  
  // Generate a cryptographically random token (32 bytes = 256 bits of entropy).
  // This goes in the email link and is the user's proof of identity.
  const resetToken = crypto.randomBytes(32).toString('hex');
  
  // Store only the HASH of the token in the database.
  // If the DB is compromised, attackers cannot use the hashed tokens
  // to reset anyone's password -- they would need the original plaintext.
  const hashedToken = crypto
    .createHash('sha256')
    .update(resetToken)
    .digest('hex');
  
  user.resetPasswordToken = hashedToken;
  user.resetPasswordExpires = Date.now() + 3600000; // 1 hour
  await user.save();
  
  // Send email
  const resetUrl = `${process.env.FRONTEND_URL}/reset-password/${resetToken}`;
  
  await transporter.sendMail({
    to: user.email,
    subject: 'Password Reset',
    html: `Click <a href="${resetUrl}">here</a> to reset your password.`
  });
  
  res.json({ message: 'If email exists, reset link sent' });
});

// Reset password
app.post('/api/auth/reset-password/:token', async (req, res) => {
  const hashedToken = crypto
    .createHash('sha256')
    .update(req.params.token)
    .digest('hex');
  
  const user = await User.findOne({
    resetPasswordToken: hashedToken,
    resetPasswordExpires: { $gt: Date.now() }
  });
  
  if (!user) {
    return res.status(400).json({ error: 'Invalid or expired token' });
  }
  
  user.password = await bcrypt.hash(req.body.password, 10);
  user.resetPasswordToken = undefined;
  user.resetPasswordExpires = undefined;
  await user.save();
  
  res.json({ message: 'Password reset successful' });
});

Security Best Practices

  1. Never store JWTs in localStorage — any XSS vulnerability gives attackers full access to the token. Use httpOnly cookies instead, which JavaScript cannot read.
  2. Use httpOnly cookies for refresh tokens — they are automatically sent with requests and invisible to client-side scripts.
  3. Implement token blacklisting for logout — without it, a “logged out” user’s token remains valid until it expires.
  4. Use short expiration for access tokens — 15 minutes is a good default. Short-lived tokens limit the window of exploitation if one is stolen.
  5. Rotate refresh tokens on each use — when a refresh token is used, issue a new one and invalidate the old one. If an attacker steals a refresh token and uses it after the real user, the mismatch triggers revocation of all tokens for that user.
  6. Hash passwords with bcrypt (cost factor 10+) — never use MD5, SHA-1, or SHA-256 for passwords. They are too fast to resist brute-force attacks.
  7. Validate password strength on registration — enforce minimum length (8+ characters), and consider checking against known breached password lists using the Have I Been Pwned API.
Common pitfall: JWT token revocation. JWTs are stateless by design, which means the server has no built-in way to invalidate them before expiration. If a user changes their password or an account is compromised, you need a revocation strategy. Common approaches include: maintaining a short blocklist of revoked tokens in Redis (checked on each request), keeping token lifetimes very short (15 minutes), or including a “token version” in the user record and bumping it on security events.