Add Auth to Your Express App: A 2026 Guide

22 Jun 2026 · Bank K.

Add authentication to your Express app with sessions, bcrypt password hashing, and protected routes. Working code for sessions and JWTs.

You scaffolded an Express app, wired up a few routes, and connected a database. Now you need to add authentication to your Express app so users can sign up, log in, and access pages that aren’t public. Express ships with none of this built in — it’s deliberately minimal — so you have to assemble the pieces yourself.

The good news: the pieces are small and well-understood. You need a password hasher, a way to persist who’s logged in (a session or a token), and a middleware that guards protected routes. This guide walks through both the session-based and JWT-based approaches with code that actually runs, plus the security details people skip and regret later.

What You’re Building

By the end, your Express app will have:

  • A signup endpoint that hashes passwords with bcrypt before storing them
  • A login endpoint that verifies credentials and starts a session
  • Middleware that rejects unauthenticated requests
  • A logout endpoint that destroys the session
  • The security headers and settings that keep all of this from being trivially exploitable

I’ll assume you have a database and a users table (or collection) with at least an email and a passwordHash column. Prisma, Drizzle, Mongoose, or raw SQL — doesn’t matter for the auth logic.

Step 1: Hash Passwords With bcrypt

Never store plaintext passwords. Never store passwords with a fast hash like SHA-256 either — those are built for speed, which is exactly what you don’t want when an attacker is brute-forcing a leaked database. Use bcrypt (or argon2id), which is intentionally slow.

npm install bcrypt
// auth/password.js
const bcrypt = require('bcrypt');

const SALT_ROUNDS = 12;

async function hashPassword(plain) {
  return bcrypt.hash(plain, SALT_ROUNDS);
}

async function verifyPassword(plain, hash) {
  return bcrypt.compare(plain, hash);
}

module.exports = { hashPassword, verifyPassword };

SALT_ROUNDS of 12 is a reasonable default in 2026 — high enough to be slow for attackers, low enough that your login endpoint stays responsive. bcrypt generates and embeds the salt automatically, so you store the single hash string and nothing else.

Step 2: Session-Based Auth With express-session

For a server-rendered app or a classic SaaS dashboard, server-side sessions are the simplest correct choice. The session ID lives in an httpOnly cookie; the actual session data lives on your server.

npm install express-session connect-redis redis
// app.js
const express = require('express');
const session = require('express-session');
const { RedisStore } = require('connect-redis');
const { createClient } = require('redis');

const app = express();
app.use(express.json());

const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect().catch(console.error);

app.use(
  session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
    },
  })
);

The default express-session memory store is fine for local development but loses every session when your process restarts and leaks memory under load. Use a real store — Redis here — before you ship.

Signup and Login Routes

const { hashPassword, verifyPassword } = require('./auth/password');
const { db } = require('./db');

app.post('/signup', async (req, res) => {
  const { email, password } = req.body;

  if (!email?.includes('@') || !password || password.length < 8) {
    return res.status(400).json({ error: 'Invalid email or password (min 8 chars)' });
  }

  const existing = await db.user.findUnique({ where: { email } });
  if (existing) {
    return res.status(409).json({ error: 'Email already registered' });
  }

  const passwordHash = await hashPassword(password);
  const user = await db.user.create({ data: { email, passwordHash } });

  // Regenerate the session to prevent fixation, then log the user in
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ error: 'Session error' });
    req.session.userId = user.id;
    res.json({ id: user.id, email: user.email });
  });
});

app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await db.user.findUnique({ where: { email } });

  // Always run the hash comparison to avoid leaking which emails exist
  const valid = user && (await verifyPassword(password, user.passwordHash));
  if (!valid) {
    return res.status(401).json({ error: 'Invalid email or password' });
  }

  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ error: 'Session error' });
    req.session.userId = user.id;
    res.json({ id: user.id, email: user.email });
  });
});

Two details worth calling out. First, req.session.regenerate() after login issues a fresh session ID — this prevents session fixation attacks where an attacker plants a known session ID before you log in. Second, returning the same error message for “wrong password” and “no such user” avoids leaking which emails are registered.

Protect Routes With Middleware

function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Authentication required' });
  }
  next();
}

app.get('/dashboard', requireAuth, async (req, res) => {
  const user = await db.user.findUnique({ where: { id: req.session.userId } });
  res.json({ message: `Welcome back, ${user.email}` });
});

Drop requireAuth in front of any route that needs a logged-in user. It’s the entire authorization layer for a basic app.

Logout

app.post('/logout', (req, res) => {
  req.session.destroy((err) => {
    if (err) return res.status(500).json({ error: 'Logout failed' });
    res.clearCookie('connect.sid');
    res.json({ message: 'Logged out' });
  });
});

Step 3: The JWT Alternative

If you’re building a stateless API — say a mobile backend or a service consumed by a separate frontend — JSON Web Tokens avoid the round-trip to a session store. The tradeoff: you can’t easily invalidate a token before it expires, so you keep expiry short.

npm install jsonwebtoken
const jwt = require('jsonwebtoken');

function issueToken(user) {
  return jwt.sign({ sub: user.id }, process.env.JWT_SECRET, {
    expiresIn: '15m',
  });
}

function requireJwt(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }
  try {
    const payload = jwt.verify(header.slice(7), process.env.JWT_SECRET);
    req.userId = payload.sub;
    next();
  } catch {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

A 15-minute access token paired with a longer-lived refresh token (stored in an httpOnly cookie and rotated on use) is the standard production pattern. Don’t store JWTs in localStorage — any XSS bug on your page can read them. Cookies with httpOnly can’t be read by JavaScript at all.

Sessions or JWTs? For most indie SaaS dashboards, sessions are simpler and safer by default. Reach for JWTs when you genuinely need stateless auth across multiple services or a native mobile client.

Tired of Wiring This Up Every Time?

Sessions, bcrypt, refresh-token rotation, password reset emails, rate limiting on login, email verification — every Express app needs the same auth machinery, and it’s never the part that makes your product unique. If you’d rather skip straight to building features, Beag drops authentication and Stripe payments into your app so you don’t rebuild this for the fourth time.

If you’re already comfortable owning your auth layer, keep reading — the security checklist below is the part most tutorials leave out.

Security Checklist Most Tutorials Skip

Rate-limit your login endpoint. Without it, attackers can brute-force passwords. Add express-rate-limit and cap login attempts per IP.

npm install express-rate-limit
const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: { error: 'Too many attempts, try again later' },
});

app.post('/login', loginLimiter, /* handler */);

Set security headers. Use helmet to add sane defaults for headers like X-Content-Type-Options and Strict-Transport-Security in one line: app.use(helmet()).

Always set secure: true cookies in production. Without it, session cookies travel over plain HTTP and can be intercepted. Gate it on NODE_ENV as shown above.

Run npm audit in CI. Auth code pulls in dependencies; a vulnerable transitive package undermines everything else. Catch it before deploy.

Don’t roll your own crypto. Use bcrypt or argon2 for passwords and jsonwebtoken for tokens. Hand-written hashing or token logic is where subtle, exploitable bugs live.

Express Auth Options in 2026

ApproachBest ForTradeoff
express-session + bcryptServer-rendered apps, dashboardsNeeds a session store (Redis)
jsonwebtokenStateless APIs, mobile backendsHard to revoke before expiry
Passport.jsMultiple OAuth providersMore setup, strategy boilerplate
BeagMVPs needing auth + paymentsLess low-level control

Passport.js remains the go-to when you need Google, GitHub, or other OAuth strategies without building each flow by hand. For email/password on a single app, the custom approach in this guide is less code than configuring Passport.

FAQ

Should I use sessions or JWTs for an Express app?

For most server-rendered apps and SaaS dashboards, use sessions with express-session and a Redis store. They’re simpler, easier to invalidate, and secure by default. Use JWTs when you need stateless auth across multiple services or a separate mobile/SPA client that can’t rely on cookies.

Is bcrypt still the right choice in 2026?

Yes. bcrypt is battle-tested and has the broadest library support. argon2id has slightly better theoretical resistance to GPU attacks, so if you’re starting fresh and your stack supports it cleanly, argon2id is a fine choice too. Both are far better than any general-purpose hash.

How do I add Google login to an Express app?

Use Passport.js with the passport-google-oauth20 strategy. Register an OAuth app in the Google Cloud Console, add your client ID and secret as environment variables, configure the strategy, and add a callback route. It plugs into the same session you set up above.

Can I add Stripe payments to my Express app too?

Yes. You can integrate Stripe directly with the stripe Node SDK in your Express routes, or use Beag to get auth and Stripe billing pre-wired so you skip the webhook and subscription plumbing entirely.

Wrapping Up

Authentication in Express comes down to three moving parts: hashing passwords, persisting login state, and guarding routes. Get bcrypt, sessions (or JWTs), and a requireAuth middleware right, layer on rate limiting and secure cookies, and you have a solid foundation.

If you’d rather not rebuild that foundation on your next project, Beag handles auth and payments so you can ship the part that actually matters. Browse more guides on the Beag blog when you’re ready for the payments side.

About the Author
Bank K.

Bank K.

Serial entrepreneur & Co-founder of Beag.io

Founder of Beag.io. Indie hacker building tools to help developers ship faster.

Ready to Make Money From Your SaaS?

Turn your SaaS into cash with Beag.io. Get started now!

Start 7-day free trial →