Authentication Security: What to Get Right Before Your First User Logs In
Authentication security fundamentals for web applications — password hashing, session management, MFA implementation, account lockout, and passkeys in 2026.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
Authentication Security: What to Get Right Before Your First User Logs In
Authentication is the foundation your entire security model rests on. Get it right and every user is who they claim to be. Get it wrong and every other security control in your application is undermined — there is no point protecting resources from unauthorized access if unauthorized people can authenticate as authorized users.
I have seen authentication implemented badly in more production applications than I care to count. The mistakes are often the same: fast password hashing, no rate limiting, inadequate session management, poorly implemented reset flows. Here is what correct looks like.
Password Hashing: The Non-Negotiable
Passwords must never be stored in plaintext. This is not a controversial statement, yet plaintext password storage shows up in breach reports regularly. When your database is compromised — and treat it as "when," not "if" — you want to ensure that your users' passwords cannot be recovered from the leaked data.
Password hashing uses a one-way function to transform a password into a hash. Given the hash, you cannot recover the password. Given the password, you can verify it produces the same hash.
Use bcrypt, Argon2id, or scrypt. These are specifically designed for password hashing — they are intentionally slow, making brute-force attacks expensive.
import bcrypt from "bcrypt";
// Hashing — use 12 rounds minimum
const BCRYPT_ROUNDS = 12;
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
Why bcrypt over SHA-256 or MD5? SHA-256 is designed to be fast — a modern GPU can compute billions of SHA-256 hashes per second, making rainbow table and brute-force attacks feasible. bcrypt with 12 rounds takes approximately 300ms per hash — fast enough for legitimate users to barely notice, slow enough that brute-forcing a database of hashed passwords is computationally infeasible.
Argon2id is the current best practice for new implementations:
import argon2 from "argon2";
async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64MB
timeCost: 3,
parallelism: 4,
});
}
async function verifyPassword(password: string, hash: string): Promise<boolean> {
return argon2.verify(hash, password);
}
Argon2id won the Password Hashing Competition in 2015 and is recommended by OWASP. It requires configuring memory cost (resistant to GPU attacks) and time cost. The parameters above are a reasonable starting point.
Password Policy in 2026
NIST's 2024 guidelines (SP 800-63B) have changed best practices significantly. Current recommendations:
- Minimum length: 8 characters (15+ recommended)
- Maximum length: at least 64 characters (many systems truncate long passwords — this is wrong)
- Do not require special characters, numbers, or mixed case (complexity requirements lead to predictable patterns)
- Do require checking against known breached passwords
- Do not force periodic password changes (unless there is evidence of compromise)
Check passwords against the Have I Been Pwned database. HIBP exposes an API for k-anonymity password checking — you send the first 5 characters of the SHA-1 hash of the password, and receive a list of hashes to check against locally. The full password never leaves your system:
async function isBreachedPassword(password: string): Promise<boolean> {
const hash = crypto.createHash("sha1").update(password).toUpperCase().digest("hex");
const prefix = hash.slice(0, 5);
const suffix = hash.slice(5);
const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
const text = await response.text();
return text.split("\r\n").some((line) => line.startsWith(suffix));
}
Block passwords that appear in breach databases. A password from a leaked database is likely in every attacker's wordlist.
Session Management
Session tokens are the credentials your application issues after successful authentication. They need the same care as passwords.
Generate session tokens with cryptographically secure randomness:
import { randomBytes } from "crypto";
function generateSessionToken(): string {
return randomBytes(32).toString("hex"); // 256 bits of entropy
}
256 bits of entropy is not guessable. Compare this to a 4-digit PIN (10,000 possibilities) or a sequential session ID (trivially enumerable).
Set appropriate cookie attributes:
res.cookie("session", token, {
httpOnly: true, // Not accessible to JavaScript
secure: true, // HTTPS only
sameSite: "lax", // Prevents CSRF
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: "/",
});
Implement session invalidation on logout. This is obvious but frequently done incorrectly. Clearing the cookie client-side without invalidating the server-side session record means the session is still valid — anyone with the token can continue using it.
async function logout(req: Request, res: Response): Promise<void> {
const token = req.cookies.session;
if (token) {
await db.session.delete({ where: { token } }); // Invalidate server-side
}
res.clearCookie("session");
res.redirect("/login");
}
Rate Limiting Authentication Endpoints
Authentication endpoints without rate limiting are vulnerable to brute-force attacks. With a weak password policy or common passwords, an attacker making thousands of login attempts can eventually authenticate.
Rate limit at multiple levels:
Per-IP rate limit — limit login attempts from a single IP. 10 attempts per 15 minutes is a reasonable starting point. Implement with exponential backoff for repeated failures.
Per-account rate limit — limit login attempts against a specific account. 5 failed attempts trigger a lockout period. This prevents targeted attacks where an attacker rotates through multiple IPs to evade per-IP limits.
Global rate limit — a sharp increase in global login attempts (credential stuffing attack) should trigger additional scrutiny or temporary CAPTCHA enforcement.
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
keyGenerator: (req) => req.ip + ":" + req.body.email, // Per IP + per account
message: { error: "Too many login attempts, try again in 15 minutes" },
});
Multi-Factor Authentication
MFA dramatically reduces the risk of compromised passwords. Even if an attacker has a user's password (from a breach, phishing, or brute force), they cannot authenticate without the second factor.
TOTP (Time-based One-Time Passwords) is the most widely supported MFA method. Google Authenticator, Authy, and most password managers support it:
import { authenticator } from "otplib";
// Generate a secret for a user during MFA setup
function generateMfaSecret(): string {
return authenticator.generateSecret();
}
// Generate a QR code URL for scanning with authenticator app
function getMfaQrUrl(email: string, secret: string): string {
return authenticator.keyuri(email, "YourApp", secret);
}
// Verify a TOTP code
function verifyMfaCode(token: string, secret: string): boolean {
return authenticator.check(token, secret);
}
Passkeys (WebAuthn) are the best authentication mechanism available in 2026. They are phishing-resistant (credentials are domain-bound), do not require passwords, and are backed by hardware (the user's device biometrics or PIN). Major browsers and platforms support them. If you are building a new application, implementing passkeys alongside traditional auth is worth the investment.
Password Reset Security
Password reset flows are a common attack vector. The secure implementation:
- User submits email
- If the email exists, generate a cryptographically random, single-use token with short expiry (15-30 minutes)
- Send a reset link containing the token to the email address
- On link click, validate the token is valid and unexpired
- Accept the new password, hash it, update the user record, invalidate the token
- Invalidate all existing sessions for the user
Critical details:
- Do not reveal whether an email address exists in your system. Return the same response regardless of whether the email is registered ("If an account exists with this email, you will receive a reset link"). This prevents user enumeration.
- Invalidate the token immediately after use — do not allow replaying the same reset link.
- Log the reset event including the IP and timestamp for security audit.
async function initiatePasswordReset(email: string): Promise<void> {
const user = await db.user.findByEmail(email);
// Always return success to prevent user enumeration
if (!user) return;
const token = randomBytes(32).toString("hex");
const expiry = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes
await db.passwordReset.upsert({
where: { userId: user.id },
update: { token, expiresAt: expiry },
create: { userId: user.id, token, expiresAt: expiry },
});
await emailService.sendPasswordReset(user.email, token);
}
The Authentication Security Checklist
Before your first user logs in:
- Passwords hashed with bcrypt (cost 12+) or Argon2id
- Passwords checked against breach databases on creation and change
- Session tokens 256 bits of entropy minimum
- Sessions invalidated on logout (server-side)
- Rate limiting on login and password reset endpoints
- MFA available (TOTP at minimum, passkeys preferred)
- Password reset tokens single-use, short-lived, randomly generated
- Account lockout after repeated failures with recovery path
- Auth events logged (login, logout, failed login, password change, MFA change)
If you want a review of your authentication implementation or help building a secure auth system from scratch, book a session at https://calendly.com/jamesrossjr.