How to Build a Secure Login Form

Share
How-To Guide

How to Build a Secure Login Form

The complete guide to authentication security

TL;DR

TL;DR (25 minutes): Use HTTPS, add CSRF tokens, hash passwords with bcrypt, implement rate limiting (5 attempts then lockout), use generic error messages ("Invalid credentials" not "User not found"), create secure session tokens, and add 2FA for sensitive accounts.

Prerequisites:

  • HTTPS enabled on your domain
  • Database with users table
  • Basic frontend and backend setup

Why This Matters

Login forms are the front door to your application. A poorly implemented login enables credential stuffing, brute force attacks, and account takeovers. 81% of breaches involve stolen or weak credentials.

Step-by-Step Guide

1

Create the secure HTML form

<!-- Always use HTTPS -->
<form
  action="/api/auth/login"
  method="POST"
  autocomplete="on"
>
  <!-- CSRF token (populated by server) -->
  <input type="hidden" name="_csrf" value="{{csrfToken}}" />

  <div>
    <label for="email">Email</label>
    <input
      type="email"
      id="email"
      name="email"
      required
      autocomplete="email"
      inputmode="email"
    />
  </div>

  <div>
    <label for="password">Password</label>
    <input
      type="password"
      id="password"
      name="password"
      required
      autocomplete="current-password"
      minlength="8"
    />
  </div>

  <button type="submit">Sign In</button>
</form>

Key attributes: autocomplete helps password managers, method="POST" keeps credentials out of URLs.

2

Implement the backend authentication

import bcrypt from 'bcrypt';
import { z } from 'zod';

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

async function login(req, res) {
  // Validate input
  const result = loginSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: 'Invalid input' });
  }

  const { email, password } = result.data;

  // Check rate limiting first
  if (await isRateLimited(email, req.ip)) {
    return res.status(429).json({
      error: 'Too many attempts. Please try again later.'
    });
  }

  // Find user
  const user = await db.user.findUnique({
    where: { email }
  });

  // IMPORTANT: Always use the same response for all failure cases
  // This prevents user enumeration
  if (!user) {
    await recordFailedAttempt(email, req.ip);
    // Simulate password hash check to prevent timing attacks
    await bcrypt.compare(password, '$2b$10$dummy.hash.for.timing');
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Verify password
  const passwordValid = await bcrypt.compare(password, user.passwordHash);

  if (!passwordValid) {
    await recordFailedAttempt(email, req.ip);
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Check if account is locked
  if (user.lockedUntil && user.lockedUntil > new Date()) {
    return res.status(401).json({
      error: 'Account temporarily locked. Please try again later.'
    });
  }

  // Clear failed attempts on success
  await clearFailedAttempts(email);

  // Create session
  const session = await createSession(user.id, req);

  // Set secure cookie
  res.cookie('session', session.token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
  });

  return res.json({ success: true });
}
3

Implement rate limiting

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_URL,
  token: process.env.UPSTASH_REDIS_TOKEN
});

// Rate limit per IP
const ipLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '15 m'), // 10 attempts per 15 min
});

// Rate limit per email (prevent targeting specific accounts)
const emailLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, '15 m'), // 5 attempts per 15 min
});

async function isRateLimited(email, ip) {
  const [ipResult, emailResult] = await Promise.all([
    ipLimiter.limit(ip),
    emailLimiter.limit(email)
  ]);

  return !ipResult.success || !emailResult.success;
}

async function recordFailedAttempt(email, ip) {
  // Increment failure counters
  await redis.incr(`failed:${email}`);
  await redis.incr(`failed:ip:${ip}`);
  await redis.expire(`failed:${email}`, 900); // 15 minutes
  await redis.expire(`failed:ip:${ip}`, 900);

  // Lock account after 5 failed attempts
  const attempts = await redis.get(`failed:${email}`);
  if (Number(attempts) >= 5) {
    await db.user.update({
      where: { email },
      data: { lockedUntil: new Date(Date.now() + 15 * 60 * 1000) }
    });
  }
}

async function clearFailedAttempts(email) {
  await redis.del(`failed:${email}`);
}
4

Create secure sessions

import crypto from 'crypto';

async function createSession(userId, req) {
  // Generate secure random token
  const token = crypto.randomBytes(32).toString('hex');

  // Store session with metadata
  const session = await db.session.create({
    data: {
      token: hashToken(token), // Store hashed token
      userId,
      userAgent: req.headers['user-agent'],
      ipAddress: req.ip,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    }
  });

  return { ...session, token }; // Return unhashed token for cookie
}

function hashToken(token) {
  return crypto.createHash('sha256').update(token).digest('hex');
}

async function validateSession(token) {
  const hashedToken = hashToken(token);

  const session = await db.session.findUnique({
    where: { token: hashedToken },
    include: { user: true }
  });

  if (!session || session.expiresAt < new Date()) {
    return null;
  }

  // Extend session on activity (sliding expiration)
  await db.session.update({
    where: { id: session.id },
    data: { expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) }
  });

  return session.user;
}
5

Implement CSRF protection

import csrf from 'csurf';
import cookieParser from 'cookie-parser';

// Setup middleware
app.use(cookieParser());
app.use(csrf({ cookie: true }));

// Add token to forms
app.get('/login', (req, res) => {
  res.render('login', { csrfToken: req.csrfToken() });
});

// Or for SPAs, send token in response header
app.get('/api/csrf-token', (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

// Frontend: Include token in requests
async function login(email, password) {
  const csrfToken = document.querySelector('input[name="_csrf"]').value;

  const response = await fetch('/api/auth/login', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken
    },
    body: JSON.stringify({ email, password }),
    credentials: 'include'
  });

  return response.json();
}
6

Add security headers

// Add these headers to login pages
app.use('/login', (req, res, next) => {
  // Prevent caching of login page
  res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
  res.set('Pragma', 'no-cache');

  // Prevent clickjacking
  res.set('X-Frame-Options', 'DENY');

  // Prevent XSS
  res.set('X-Content-Type-Options', 'nosniff');

  next();
});

Security Checklist:

  • Never log passwords, even during debugging
  • Use constant-time comparison for tokens (bcrypt does this automatically)
  • Always use HTTPS - no exceptions
  • Generic error messages prevent user enumeration
  • Rate limit by both IP and email/username
  • Set secure cookie attributes (httpOnly, secure, sameSite)
  • Implement account lockout after failed attempts
  • Consider 2FA for sensitive applications

How to Verify It Worked

  1. Test HTTPS: Ensure login only works over HTTPS
  2. Test rate limiting: Try 10+ login attempts rapidly
  3. Test user enumeration: Check that "user not found" and "wrong password" return identical responses
  4. Check cookies: Verify httpOnly, secure, and sameSite flags
  5. Test CSRF: Submit form without valid token

Common Errors & Troubleshooting

CSRF token mismatch

Ensure cookies are being sent (credentials: 'include') and the token matches between form and cookie.

Session not persisting

Check cookie domain, secure flag (must use HTTPS in production), and sameSite settings.

Password comparison always fails

Verify the stored hash was created with bcrypt. Check encoding issues with special characters.

Should I use JWTs or sessions for login?

Server-side sessions are generally more secure for login because you can revoke them instantly. JWTs are better for stateless APIs. For most web apps, use sessions.

::faq-item{question="How do I implement "Remember Me"?"} Use a longer-lived session (30 days) stored in a separate "remember" cookie. On the server, track whether the session came from a "remember" token and require re-authentication for sensitive actions. ::

Should I use magic links instead of passwords?

Magic links eliminate password-related vulnerabilities but shift risk to email security. They're good for apps where users don't log in frequently. For daily use, passwords with 2FA are often better UX.

Related guides:Hash Passwords Securely · Session Management · Two-Factor Authentication

How-To Guides

How to Build a Secure Login Form