Understanding Authentication
Authentication is the process of verifying that a user is who they claim to be. It answers the question "Who are you?" and is distinct from authorization, which answers "What are you allowed to do?" Authentication is the foundation of application security — if your authentication system is compromised, all other security measures become meaningless.
There are three categories of authentication factors: something you know (passwords, PINs), something you have (phone, hardware token, smart card), and something you are (fingerprint, face recognition, voice). Strong authentication systems combine multiple factors from different categories — this is called multi-factor authentication (MFA).
Password Hashing
Passwords should never be stored in plain text or with reversible encryption. Instead, they must be hashed using a purpose-built password hashing algorithm. A hash function is a one-way function: you can compute the hash from the password, but you cannot compute the password from the hash. When a user logs in, you hash the provided password and compare it against the stored hash.
Not all hash functions are suitable for passwords. General-purpose hash functions like SHA-256 are too fast — an attacker with a modern GPU can compute billions of SHA-256 hashes per second. Password hashing algorithms like bcrypt, scrypt, and Argon2 are deliberately slow and memory-intensive, making brute-force attacks impractical.
import bcrypt from "bcrypt";
import crypto from "crypto";
// Password hashing with bcrypt
const SALT_ROUNDS = 12; // Increase as hardware gets faster
async function hashPassword(plainPassword: string): Promise<string> {
// bcrypt automatically generates a unique salt for each password
// The salt is embedded in the resulting hash string
return bcrypt.hash(plainPassword, SALT_ROUNDS);
}
async function verifyPassword(
plainPassword: string,
storedHash: string
): Promise<boolean> {
// bcrypt.compare is timing-safe (prevents timing attacks)
return bcrypt.compare(plainPassword, storedHash);
}
// Password strength validation
function validatePasswordStrength(password: string): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (password.length < 12) {
errors.push("Password must be at least 12 characters long");
}
if (password.length > 128) {
errors.push("Password must not exceed 128 characters");
}
if (!/[a-z]/.test(password)) {
errors.push("Password must contain at least one lowercase letter");
}
if (!/[A-Z]/.test(password)) {
errors.push("Password must contain at least one uppercase letter");
}
if (!/[0-9]/.test(password)) {
errors.push("Password must contain at least one number");
}
if (!/[^a-zA-Z0-9]/.test(password)) {
errors.push("Password must contain at least one special character");
}
// Check against common passwords (use a proper list in production)
const commonPasswords = ["password123", "qwerty123", "admin123"];
if (commonPasswords.includes(password.toLowerCase())) {
errors.push("This password is too common");
}
return { valid: errors.length === 0, errors };
}
Security Warning: Password Storage
- Never store plain text passwords: If your database is breached, all user accounts are immediately compromised.
- Never use MD5 or SHA for passwords: These are general-purpose hash functions that are far too fast for password hashing.
- Never use a single global salt: Each password must have its own unique salt. bcrypt handles this automatically.
- Never implement custom hashing: Use bcrypt, scrypt, or Argon2id. These are battle-tested and peer-reviewed.
Session Management
After a user authenticates, you need to maintain their authenticated state across requests. The two main approaches are server-side sessions and token-based authentication (JWTs). Each has trade-offs, and the right choice depends on your architecture.
import session from "express-session";
import RedisStore from "connect-redis";
import { createClient } from "redis";
// Secure session configuration
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET!, // Strong, random secret
name: "__Host-sid", // __Host- prefix enforces Secure + Path=/
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // Only sent over HTTPS
httpOnly: true, // Not accessible via JavaScript
sameSite: "lax", // CSRF protection
maxAge: 1800000, // 30 minutes
domain: undefined, // Only current domain
path: "/", // Available site-wide
},
})
);
// Session regeneration after login (prevent session fixation)
async function loginUser(req: Request, user: User) {
return new Promise<void>((resolve, reject) => {
// Destroy old session and create new one
req.session.regenerate((err) => {
if (err) return reject(err);
req.session.userId = user.id;
req.session.role = user.role;
req.session.loginTime = Date.now();
req.session.save((err) => {
if (err) return reject(err);
resolve();
});
});
});
}
// Session validation middleware
function requireAuth(req: Request, res: Response, next: NextFunction) {
if (!req.session?.userId) {
return res.status(401).json({ error: "Authentication required" });
}
// Check session age (absolute timeout)
const maxSessionAge = 8 * 60 * 60 * 1000; // 8 hours
if (Date.now() - req.session.loginTime > maxSessionAge) {
req.session.destroy(() => {});
return res.status(401).json({ error: "Session expired" });
}
next();
}
Multi-Factor Authentication (MFA)
MFA significantly strengthens authentication by requiring users to provide two or more verification factors. Even if an attacker obtains a user's password through phishing or a data breach, they still cannot access the account without the second factor. The most common second factor is a Time-based One-Time Password (TOTP) generated by an authenticator app.
import { authenticator } from "otplib";
import qrcode from "qrcode";
// Generate TOTP secret for a user
async function setupMFA(userId: string, userEmail: string) {
const secret = authenticator.generateSecret();
// Store the secret securely (encrypted) in the database
await db.query(
"UPDATE users SET mfa_secret = pgp_sym_encrypt($1, $2), mfa_enabled = false WHERE id = $3",
[secret, process.env.ENCRYPTION_KEY, userId]
);
// Generate QR code for authenticator app
const otpauthUrl = authenticator.keyuri(userEmail, "MyApp", secret);
const qrCodeDataUrl = await qrcode.toDataURL(otpauthUrl);
return {
secret, // Show to user as backup
qrCode: qrCodeDataUrl,
};
}
// Verify TOTP code and enable MFA
async function verifyAndEnableMFA(
userId: string,
token: string
): Promise<boolean> {
const result = await db.query(
"SELECT pgp_sym_decrypt(mfa_secret, $1) as secret FROM users WHERE id = $2",
[process.env.ENCRYPTION_KEY, userId]
);
const secret = result.rows[0]?.secret;
if (!secret) return false;
const isValid = authenticator.verify({ token, secret });
if (isValid) {
// Generate backup codes
const backupCodes = Array.from({ length: 10 }, () =>
crypto.randomBytes(4).toString("hex")
);
// Store hashed backup codes
const hashedCodes = await Promise.all(
backupCodes.map((code) => bcrypt.hash(code, 10))
);
await db.query(
"UPDATE users SET mfa_enabled = true, backup_codes = $1 WHERE id = $2",
[JSON.stringify(hashedCodes), userId]
);
return true;
}
return false;
}
// Full login flow with MFA
async function loginWithMFA(
email: string,
password: string,
mfaToken?: string
) {
const user = await findUserByEmail(email);
if (!user) throw new AuthError("Invalid credentials");
const passwordValid = await verifyPassword(password, user.passwordHash);
if (!passwordValid) throw new AuthError("Invalid credentials");
if (user.mfaEnabled) {
if (!mfaToken) {
return { requiresMFA: true, tempToken: generateTempToken(user.id) };
}
const mfaValid = authenticator.verify({
token: mfaToken,
secret: user.mfaSecret,
});
if (!mfaValid) throw new AuthError("Invalid MFA code");
}
return { user, session: await createSession(user) };
}
Account Lockout and Brute Force Protection
Brute force protection is essential to prevent attackers from guessing passwords by trying thousands of combinations. Implement progressive delays, account lockouts, and rate limiting. However, be careful not to create a denial-of-service vulnerability where an attacker can lock out legitimate users.
// Progressive delay brute force protection
class LoginRateLimiter {
private attempts: Map<string, { count: number; lastAttempt: number }> = new Map();
async checkAndRecord(identifier: string): Promise<{
allowed: boolean;
retryAfter?: number;
}> {
const now = Date.now();
const record = this.attempts.get(identifier) || { count: 0, lastAttempt: 0 };
// Reset after 15 minutes of no attempts
if (now - record.lastAttempt > 15 * 60 * 1000) {
record.count = 0;
}
record.count++;
record.lastAttempt = now;
this.attempts.set(identifier, record);
// Progressive delays: 0, 0, 0, 2s, 4s, 8s, 16s, 32s...
if (record.count > 3) {
const delaySeconds = Math.pow(2, record.count - 3);
const maxDelay = 300; // Cap at 5 minutes
const actualDelay = Math.min(delaySeconds, maxDelay);
return { allowed: false, retryAfter: actualDelay };
}
return { allowed: true };
}
reset(identifier: string): void {
this.attempts.delete(identifier);
}
}
// Use both IP and email as identifiers
const limiter = new LoginRateLimiter();
app.post("/api/login", async (req, res) => {
const ipCheck = await limiter.checkAndRecord(`ip:${req.ip}`);
const emailCheck = await limiter.checkAndRecord(`email:${req.body.email}`);
if (!ipCheck.allowed) {
return res.status(429).json({
error: "Too many attempts",
retryAfter: ipCheck.retryAfter,
});
}
if (!emailCheck.allowed) {
return res.status(429).json({
error: "Too many attempts",
retryAfter: emailCheck.retryAfter,
});
}
// Process login...
});
Authentication Best Practices Summary
- Use bcrypt or Argon2id: For password hashing with appropriate cost factors.
- Enforce MFA: Especially for admin accounts and sensitive operations.
- Regenerate sessions: After login to prevent session fixation attacks.
- Set secure cookie flags: HttpOnly, Secure, SameSite, and appropriate expiry.
- Implement rate limiting: On login endpoints using both IP and email-based tracking.
- Use generic error messages: Say "Invalid credentials" instead of "User not found" or "Wrong password".
- Log authentication events: Record all login attempts, successes, and failures for audit purposes.