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
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.
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 });
}
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}`);
}
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;
}
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();
}
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
- Test HTTPS: Ensure login only works over HTTPS
- Test rate limiting: Try 10+ login attempts rapidly
- Test user enumeration: Check that "user not found" and "wrong password" return identical responses
- Check cookies: Verify httpOnly, secure, and sameSite flags
- 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