Published
- 40 min read
Creating Secure Login Systems with React and Node.js
How to Write, Ship, and Maintain Code Without Shipping Vulnerabilities
A hands-on security guide for developers and IT professionals who ship real software. Build, deploy, and maintain secure systems without slowing down or drowning in theory.
Buy the book now
Practical Digital Survival for Whistleblowers, Journalists, and Activists
A practical guide to digital anonymity for people who can’t afford to be identified. Designed for whistleblowers, journalists, and activists operating under real-world risk.
Buy the book now
The Digital Fortress: How to Stay Safe Online
A simple, no-jargon guide to protecting your digital life from everyday threats. Learn how to secure your accounts, devices, and privacy with practical steps anyone can follow.
Buy the book nowIntroduction
Building secure login systems is one of the most critical tasks for developers. A well-designed authentication flow ensures that user data is protected while providing a seamless experience. This article provides a comprehensive guide to creating a secure login system using React for the frontend and Node.js for the backend. We’ll explore authentication concepts, implementation steps, and best practices.
Why Security Matters in Login Systems
1. Protecting User Data
Login systems often handle sensitive information like passwords and personal details, making them a prime target for attackers.
2. Preventing Unauthorized Access
Ensuring secure authentication prevents unauthorized access to user accounts and sensitive resources.
3. Building User Trust
A secure login process fosters user confidence in your application, contributing to better user retention.
Architecture of the Secure Login System
The secure login system will follow these components:
- Frontend (React):
- Handles user input and communicates with the backend API.
- Manages session state using tokens.
- Backend (Node.js):
- Authenticates users using hashed passwords.
- Issues JSON Web Tokens (JWTs) for session management.
- Database:
- Stores user credentials securely using hashing techniques.
- Security Measures:
- Implements HTTPS for encrypted communication.
- Enforces rate limiting and brute force protection.
Implementation Steps
Step 1: Setting Up the Backend (Node.js)
1.1 Install Dependencies
npm init -y
npm install express bcrypt jsonwebtoken dotenv cors mongoose
1.2 Create User Model
const mongoose = require('mongoose')
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
password: { type: String, required: true }
})
module.exports = mongoose.model('User', userSchema)
1.3 Implement Authentication Endpoints
const express = require('express')
const bcrypt = require('bcrypt')
const jwt = require('jsonwebtoken')
const User = require('./models/User')
const app = express()
app.use(express.json())
// Register Endpoint
app.post('/register', async (req, res) => {
const { email, password } = req.body
const hashedPassword = await bcrypt.hash(password, 10)
const user = new User({ email, password: hashedPassword })
await user.save()
res.status(201).send('User registered')
})
// Login Endpoint
app.post('/login', async (req, res) => {
const { email, password } = req.body
const user = await User.findOne({ email })
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).send('Invalid credentials')
}
const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { expiresIn: '1h' })
res.json({ token })
})
app.listen(5000, () => console.log('Server running on http://localhost:5000'))
Step 2: Setting Up the Frontend (React)
2.1 Install Dependencies
npx create-react-app secure-login
cd secure-login
npm install axios react-router-dom
2.2 Build Login Form
import React, { useState } from 'react'
import axios from 'axios'
function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
try {
const { data } = await axios.post('http://localhost:5000/login', { email, password })
localStorage.setItem('token', data.token)
alert('Login successful')
} catch (error) {
alert('Invalid credentials')
}
}
return (
<form onSubmit={handleSubmit}>
<input
type='email'
placeholder='Email'
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type='password'
placeholder='Password'
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type='submit'>Login</button>
</form>
)
}
export default Login
Step 3: Enhancing Security
3.1 Use HTTPS
- Obtain an SSL certificate and configure your backend server to use HTTPS.
3.2 Secure Password Storage
- Use bcrypt with a high work factor for hashing passwords.
3.3 Implement Rate Limiting
const rateLimit = require('express-rate-limit')
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // Limit each IP to 100 requests per windowMs
})
app.use(limiter)
3.4 Validate Inputs
const { body, validationResult } = require('express-validator')
app.post(
'/register',
[body('email').isEmail(), body('password').isLength({ min: 8 })],
(req, res) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() })
}
// Proceed with registration
}
)
Authentication Flow Walkthrough
Before diving deeper into hardening the system, it helps to understand the full lifecycle of an authentication request. The diagram below traces what happens from the moment a user submits their credentials to when they receive a protected API response.
sequenceDiagram
participant U as User (Browser)
participant R as React Frontend
participant N as Node.js Backend
participant DB as Database
U->>R: Fill login form & submit
R->>N: POST /login { email, password }
N->>DB: Find user by email
DB-->>N: User record (hashed password)
N->>N: bcrypt.compare(password, hash)
alt Credentials valid
N->>N: jwt.sign({ id }, secret, { expiresIn })
N-->>R: 200 OK + Set-Cookie: refreshToken (httpOnly)
R->>R: Store access token in memory
R-->>U: Redirect to dashboard
else Credentials invalid
N-->>R: 401 "Invalid credentials"
R-->>U: Show error message
end
U->>R: Request protected resource
R->>N: GET /api/profile (Authorization: Bearer <token>)
N->>N: jwt.verify(token, secret)
alt Token valid
N->>DB: Fetch user data
DB-->>N: User data
N-->>R: 200 OK + user data
else Token expired/invalid
N-->>R: 401 Unauthorized
R->>N: POST /token/refresh (sends httpOnly refresh cookie)
N-->>R: New access token
R->>N: Retry original request
end
Every step in this diagram corresponds to a decision point where a security mistake can be introduced. The following sections walk through each of those decision points in detail and show you how to handle them correctly.
Secure Token Storage: HTTP-Only Cookies vs localStorage
One of the most consequential choices you will make is where to store the access token on the client. The initial code example in this article uses localStorage, which is simple but introduces Cross-Site Scripting (XSS) risk: any injected script can read localStorage and exfiltrate the token. OWASP explicitly recommends using HTTP-only cookies for session credentials.
Comparison Table
| Criteria | localStorage | HTTP-only Cookie | In-Memory (React state) |
|---|---|---|---|
| XSS resistance | Poor — JS can read it | Excellent — JS cannot access | Excellent — cleared on tab close |
| CSRF resistance | Excellent — not auto-sent | Requires CSRF token | Excellent |
| Survives page refresh | Yes | Yes | No (needs refresh rotation) |
| Implementation complexity | Low | Medium | Medium |
| Recommended for | Non-sensitive data only | Refresh tokens | Short-lived access tokens |
The industry best practice today is a hybrid approach: store the short-lived access token in React memory (JavaScript variable), and store the long-lived refresh token in an HTTP-only Secure cookie. The access token is never written to disk.
Setting an HTTP-Only Cookie in Node.js
Replace the res.json({ token }) call in your login endpoint with this:
// Backend: issue a refresh token as an httpOnly cookie
app.post('/login', async (req, res) => {
const { email, password } = req.body
const user = await User.findOne({ email })
if (!user || !(await bcrypt.compare(password, user.password))) {
// Return a generic message — never reveal which field was wrong
return res.status(401).json({ message: 'Invalid credentials' })
}
const accessToken = jwt.sign({ id: user._id }, process.env.JWT_ACCESS_SECRET, {
expiresIn: '15m'
})
const refreshToken = jwt.sign({ id: user._id }, process.env.JWT_REFRESH_SECRET, {
expiresIn: '7d'
})
// Store refreshToken hash in DB so it can be revoked
user.refreshToken = await bcrypt.hash(refreshToken, 10)
await user.save()
res
.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
})
.json({ accessToken })
})
Storing the Access Token in React Memory
// AuthContext.jsx — store the access token in React context, never in localStorage
import { createContext, useContext, useState, useCallback } from 'react'
import axios from 'axios'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [accessToken, setAccessToken] = useState(null)
const login = useCallback(async (email, password) => {
const { data } = await axios.post(
'/api/login',
{ email, password },
{
withCredentials: true // required to send/receive cookies
}
)
setAccessToken(data.accessToken)
}, [])
const logout = useCallback(async () => {
await axios.post('/api/logout', {}, { withCredentials: true })
setAccessToken(null)
}, [])
return (
<AuthContext.Provider value={{ accessToken, login, logout }}>{children}</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)
Implementing Token Refresh in Node.js
Because access tokens live in memory, they disappear when the user refreshes the page. A silent refresh mechanism reads the refresh cookie and issues a new access token without user interaction. This gives users seamless sessions without storing the access token anywhere persistent.
Refresh Endpoint
// POST /token/refresh
app.post('/token/refresh', async (req, res) => {
const token = req.cookies.refreshToken
if (!token) return res.status(401).json({ message: 'No refresh token' })
let payload
try {
payload = jwt.verify(token, process.env.JWT_REFRESH_SECRET)
} catch {
return res.status(403).json({ message: 'Invalid refresh token' })
}
const user = await User.findById(payload.id)
if (!user || !user.refreshToken) {
return res.status(403).json({ message: 'Refresh token revoked' })
}
// Verify stored hash matches incoming token (prevents token reuse after revocation)
const isValid = await bcrypt.compare(token, user.refreshToken)
if (!isValid) return res.status(403).json({ message: 'Refresh token mismatch' })
// Rotate the refresh token on every use
const newRefreshToken = jwt.sign({ id: user._id }, process.env.JWT_REFRESH_SECRET, {
expiresIn: '7d'
})
user.refreshToken = await bcrypt.hash(newRefreshToken, 10)
await user.save()
const newAccessToken = jwt.sign({ id: user._id }, process.env.JWT_ACCESS_SECRET, {
expiresIn: '15m'
})
res
.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
})
.json({ accessToken: newAccessToken })
})
// Logout: clear the cookie and revoke the token in DB
app.post('/logout', async (req, res) => {
const token = req.cookies.refreshToken
if (token) {
try {
const { id } = jwt.verify(token, process.env.JWT_REFRESH_SECRET)
await User.findByIdAndUpdate(id, { refreshToken: null })
} catch {
/* token already expired — nothing to revoke */
}
}
res.clearCookie('refreshToken').sendStatus(204)
})
Axios Interceptor for Silent Refresh in React
// api.js — create an axios instance that handles 401s automatically
import axios from 'axios'
const api = axios.create({ baseURL: '/api', withCredentials: true })
let accessToken = null
export const setAccessToken = (token) => {
accessToken = token
}
export const getAccessToken = () => accessToken
api.interceptors.request.use((config) => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`
}
return config
})
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config
if (error.response?.status === 401 && !original._retry) {
original._retry = true
try {
const { data } = await axios.post(
'/api/token/refresh',
{},
{
withCredentials: true
}
)
setAccessToken(data.accessToken)
original.headers.Authorization = `Bearer ${data.accessToken}`
return api(original)
} catch {
setAccessToken(null)
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
export default api
This interceptor silently retries failed requests after obtaining a fresh access token, making the expired-token case completely invisible to users.
Protecting Routes in React
Once authentication state lives in context, you can create a reusable <ProtectedRoute> component that gates any page behind a login check. This pattern keeps route protection declarative and easy to audit.
// ProtectedRoute.jsx
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from './AuthContext'
export function ProtectedRoute({ children }) {
const { accessToken } = useAuth()
const location = useLocation()
if (!accessToken) {
// Preserve the intended destination so we can redirect after login
return <Navigate to='/login' state={{ from: location }} replace />
}
return children
}
Wire it up in your router:
// App.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from './AuthContext'
import { ProtectedRoute } from './ProtectedRoute'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Settings from './pages/Settings'
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path='/login' element={<Login />} />
<Route
path='/dashboard'
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path='/settings'
element={
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
</AuthProvider>
)
}
After a successful login, redirect the user back to their originally requested page:
// Login.jsx — redirect to the page the user originally wanted to visit
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuth } from './AuthContext'
export default function Login() {
const { login } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const redirectTo = location.state?.from?.pathname || '/dashboard'
const handleSubmit = async (e) => {
e.preventDefault()
const { email, password } = Object.fromEntries(new FormData(e.target))
try {
await login(email, password)
navigate(redirectTo, { replace: true })
} catch {
// Show a generic error — never expose whether email or password was wrong
setError('Invalid credentials. Please try again.')
}
}
// ... render form
}
Input Validation and Generic Error Messages
A subtle but critical security requirement from OWASP is that login error messages must be generic. If your API returns “Password is wrong” vs “Email not found”, an attacker can enumerate valid email addresses. Both failures should produce the same response: "Invalid credentials".
Timing attacks are equally dangerous. If a user does not exist you may skip the bcrypt.compare call and respond faster, leaking the existence of accounts through response times. Always run the comparison regardless:
// Constant-time login: always run bcrypt, even for non-existent users
const DUMMY_HASH = await bcrypt.hash('dummy', 10)
app.post('/login', async (req, res) => {
const { email, password } = req.body
const user = await User.findOne({ email })
// If the user doesn't exist, compare against a dummy hash so the
// timing of the response is the same whether the user exists or not
const hashToCompare = user ? user.password : DUMMY_HASH
const match = await bcrypt.compare(password, hashToCompare)
if (!user || !match) {
return res.status(401).json({ message: 'Invalid credentials' })
}
// ... issue tokens
})
Server-Side Validation with express-validator
Always validate and sanitize input on the server — client-side validation is a UX aid, not a security control:
import { body, validationResult } from 'express-validator'
const registerValidation = [
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('password')
.isLength({ min: 12 })
.withMessage('Password must be at least 12 characters')
.matches(/[A-Z]/)
.withMessage('Password must contain an uppercase letter')
.matches(/[0-9]/)
.withMessage('Password must contain a number')
]
app.post('/register', registerValidation, async (req, res) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() })
}
// Proceed with registration
})
Note that NIST SP 800-63B guidelines recommend a minimum of 15 characters when MFA is not enabled, and at least 8 characters when it is. Prioritise length over character-class requirements — long passphrases are more secure than short complex passwords. Also consider integrating with the HaveIBeenPwned Passwords API to block credentials that appear in known breach databases.
Security Headers and CORS Configuration
Even the best JWT implementation can be undermined by missing HTTP security headers. Install helmet to automatically set relevant headers, and configure CORS to restrict which origins can call your API:
import helmet from 'helmet'
import cors from 'cors'
const app = express()
// Helmet sets X-Frame-Options, X-Content-Type-Options, HSTS, etc.
app.use(helmet())
// Only allow your React frontend to call the API
app.use(
cors({
origin: process.env.CLIENT_ORIGIN, // e.g. 'https://myapp.com'
credentials: true, // Required for cookies to be sent cross-origin
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
})
)
// Content-Security-Policy helps mitigate XSS
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:']
}
})
)
JWT Verification Middleware
Always verify the access token signature and expiry on every protected route:
// middleware/authenticate.js
import jwt from 'jsonwebtoken'
export function authenticate(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Missing token' })
}
const token = authHeader.split(' ')[1]
try {
req.user = jwt.verify(token, process.env.JWT_ACCESS_SECRET)
next()
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ message: 'Token expired' })
}
return res.status(403).json({ message: 'Invalid token' })
}
}
// Usage
app.get('/api/profile', authenticate, async (req, res) => {
const user = await User.findById(req.user.id).select('-password -refreshToken')
res.json(user)
})
Common Mistakes and Anti-Patterns
Authentication is an area where even experienced developers make costly mistakes. The table below summarizes the most common ones and their mitigations.
| Anti-Pattern | Risk | Fix |
|---|---|---|
Storing JWT in localStorage | XSS can steal tokens | Use HTTP-only cookies + in-memory access tokens |
| Long-lived access tokens (24h+) | Stolen token grants long-lived access | Keep access tokens to 15 minutes |
| No refresh token rotation | Refresh token theft goes undetected | Issue a new refresh token on every use |
| Revealing which field was wrong on login | User enumeration | Always return generic “Invalid credentials” |
jwt.sign without expiresIn | Token never expires | Always set expiresIn |
Trusting alg: none in JWT header | Authentication bypass | Explicitly specify the algorithm in jwt.verify |
No rate limiting on /login | Brute-force attacks | Apply express-rate-limit to auth endpoints |
| Logging full request bodies on login | Passwords in logs | Sanitize auth routes before logging |
| Skipping HTTPS in production | Credentials intercepted in transit | Enforce HTTPS and HSTS |
| Allowing weak passwords | Credential stuffing | Enforce minimum length + check against breached password lists |
The alg: none Vulnerability
One particularly dangerous JWT pitfall: if your verification code doesn’t explicitly specify the expected algorithm, an attacker can craft a token with "alg": "none" in the header, removing the signature entirely:
// VULNERABLE — never do this
jwt.verify(token, secret)
// SAFE — always specify the algorithm explicitly
jwt.verify(token, secret, { algorithms: ['HS256'] })
Insecure Direct Object References
Authentication verifies who a user is. Authorization controls what they can do. Skipping authorization on resource-level operations leads to IDOR vulnerabilities:
// VULNERABLE — any logged-in user can fetch any user's data
app.get('/api/users/:id', authenticate, async (req, res) => {
const user = await User.findById(req.params.id)
res.json(user)
})
// SAFE — users can only access their own data
app.get('/api/users/:id', authenticate, async (req, res) => {
if (req.params.id !== req.user.id.toString()) {
return res.status(403).json({ message: 'Forbidden' })
}
const user = await User.findById(req.params.id).select('-password')
res.json(user)
})
Adding Time-Based One-Time Passwords (TOTP) for MFA
Multi-factor authentication is the single most effective control against account compromise. Microsoft research indicates that MFA blocks over 99.9% of credential-based attacks. TOTP (used by Google Authenticator and Authy) is a widely adopted second factor that requires no SMS infrastructure.
Install the otpauth or speakeasy library:
npm install speakeasy qrcode
Enroll a User in TOTP
import speakeasy from 'speakeasy'
import QRCode from 'qrcode'
// Step 1: generate and store a TOTP secret when the user enables MFA
app.post('/mfa/setup', authenticate, async (req, res) => {
const secret = speakeasy.generateSecret({
name: `MyApp (${req.user.email})`
})
// Store the base32 secret — do NOT store the otpauth_url
await User.findByIdAndUpdate(req.user.id, {
mfaSecret: secret.base32,
mfaEnabled: false // not enabled until first successful verification
})
const qrCodeDataUrl = await QRCode.toDataURL(secret.otpauth_url)
res.json({ qrCode: qrCodeDataUrl })
})
// Step 2: verify the first TOTP token to confirm setup
app.post('/mfa/verify-setup', authenticate, async (req, res) => {
const { token } = req.body
const user = await User.findById(req.user.id)
const isValid = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token,
window: 1 // Allow 30-second clock skew
})
if (!isValid) return res.status(400).json({ message: 'Invalid code' })
await User.findByIdAndUpdate(req.user.id, { mfaEnabled: true })
res.json({ message: 'MFA enabled successfully' })
})
Checking TOTP During Login
Modify the login flow to require a second step for MFA-enabled accounts:
app.post('/login', async (req, res) => {
const { email, password, totpToken } = req.body
const user = await User.findOne({ email })
const DUMMY_HASH = await bcrypt.hash('x', 10)
const match = await bcrypt.compare(password, user ? user.password : DUMMY_HASH)
if (!user || !match) return res.status(401).json({ message: 'Invalid credentials' })
if (user.mfaEnabled) {
if (!totpToken) {
// Signal to the frontend that it should request a TOTP code
return res.status(200).json({ mfaRequired: true })
}
const mfaValid = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token: totpToken,
window: 1
})
if (!mfaValid) return res.status(401).json({ message: 'Invalid MFA code' })
}
// Issue tokens as normal
// ...
})
React MFA Step Component
// LoginForm.jsx — handles both password and optional MFA steps
export default function LoginForm() {
const [step, setStep] = useState('credentials') // 'credentials' | 'mfa'
const [credentials, setCredentials] = useState({ email: '', password: '' })
const [totpToken, setTotpToken] = useState('')
const handleCredentials = async (e) => {
e.preventDefault()
const result = await api.post('/login', credentials)
if (result.data.mfaRequired) {
setStep('mfa')
} else {
// Login complete
}
}
const handleMfa = async (e) => {
e.preventDefault()
await api.post('/login', { ...credentials, totpToken })
// Login complete
}
if (step === 'mfa') {
return (
<form onSubmit={handleMfa}>
<label>Enter the 6-digit code from your authenticator app</label>
<input
type='text'
inputMode='numeric'
pattern='[0-9]{6}'
maxLength={6}
value={totpToken}
onChange={(e) => setTotpToken(e.target.value)}
autoComplete='one-time-code'
required
/>
<button type='submit'>Verify</button>
</form>
)
}
return <form onSubmit={handleCredentials}>{/* email and password fields */}</form>
}
Testing Your Authentication System
Untested security code is untrusted security code. Authentication logic should be covered by both unit tests (for individual functions like token signing) and integration tests (for the full HTTP request/response cycle).
Unit Tests with Jest
// auth.test.js
import jwt from 'jsonwebtoken'
import bcrypt from 'bcrypt'
describe('Token utilities', () => {
const secret = 'test-secret'
test('signed token contains expected payload', () => {
const payload = { id: 'user123' }
const token = jwt.sign(payload, secret, { expiresIn: '15m' })
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] })
expect(decoded.id).toBe('user123')
})
test('expired token throws TokenExpiredError', () => {
const token = jwt.sign({ id: 'user123' }, secret, { expiresIn: '0s' })
expect(() => jwt.verify(token, secret, { algorithms: ['HS256'] })).toThrow('jwt expired')
})
test('bcrypt hash does not match wrong password', async () => {
const hash = await bcrypt.hash('correct-password', 10)
const result = await bcrypt.compare('wrong-password', hash)
expect(result).toBe(false)
})
})
Integration Tests with Supertest
// auth.integration.test.js
import request from 'supertest'
import app from '../server.js'
import mongoose from 'mongoose'
import User from '../models/User.js'
beforeAll(async () => {
await mongoose.connect(process.env.MONGODB_TEST_URI)
})
afterEach(async () => {
await User.deleteMany({})
})
afterAll(async () => {
await mongoose.connection.close()
})
describe('POST /register', () => {
test('returns 201 for valid registration', async () => {
const res = await request(app)
.post('/register')
.send({ email: '[email protected]', password: 'SecurePass123!' })
expect(res.status).toBe(201)
})
test('returns 400 for weak password', async () => {
const res = await request(app)
.post('/register')
.send({ email: '[email protected]', password: 'weak' })
expect(res.status).toBe(400)
})
})
describe('POST /login', () => {
beforeEach(async () => {
const hash = await bcrypt.hash('SecurePass123!', 10)
await User.create({ email: '[email protected]', password: hash })
})
test('returns 200 and access token for valid credentials', async () => {
const res = await request(app)
.post('/login')
.send({ email: '[email protected]', password: 'SecurePass123!' })
expect(res.status).toBe(200)
expect(res.body).toHaveProperty('accessToken')
})
test('returns 401 for invalid credentials', async () => {
const res = await request(app)
.post('/login')
.send({ email: '[email protected]', password: 'WrongPassword!' })
expect(res.status).toBe(401)
// Ensure the error message doesn't reveal which field was wrong
expect(res.body.message).toBe('Invalid credentials')
})
test('returns 401 for non-existent user without leaking info', async () => {
const res = await request(app)
.post('/login')
.send({ email: '[email protected]', password: 'AnyPassword123!' })
expect(res.status).toBe(401)
expect(res.body.message).toBe('Invalid credentials')
})
test('sets httpOnly refresh token cookie', async () => {
const res = await request(app)
.post('/login')
.send({ email: '[email protected]', password: 'SecurePass123!' })
const cookie = res.headers['set-cookie']?.[0]
expect(cookie).toMatch(/HttpOnly/)
expect(cookie).toMatch(/refreshToken/)
})
})
describe('Protected route', () => {
test('returns 401 with no token', async () => {
const res = await request(app).get('/api/profile')
expect(res.status).toBe(401)
})
test('returns 403 with invalid token', async () => {
const res = await request(app)
.get('/api/profile')
.set('Authorization', 'Bearer invalid.token.here')
expect(res.status).toBe(403)
})
})
These tests cover the key security properties: correct credentials are accepted, incorrect ones are rejected with a generic message, the refresh token is HTTP-only, and protected routes block unauthenticated requests. Running these in CI ensures regressions are caught before they reach production.
A good testing strategy for authentication goes beyond happy-path and basic error cases. Consider testing boundary conditions: what happens when the JWT secret is rotated mid-session? What happens when the database is temporarily unavailable during a refresh? What happens when the same refresh token is submitted twice in rapid succession after rotation? These edge cases are where subtle security bugs hide. Property-based testing with a library like fast-check can help by generating random inputs — valid tokens with modified payloads, tokens with tampered signatures, payloads at the boundary of valid claim values — and verifying that your verification logic rejects all of them. End-to-end testing with Playwright or Cypress adds another layer by exercising the full browser flow including cookie behaviour, redirect chains, and the silent refresh fallback, giving you confidence that the React frontend and Node.js backend integrate correctly in a real browser context.
Best Practices
- Avoid Storing Tokens on the Client-Side
- Use HTTP-only cookies instead of localStorage for storing JWTs.
- Implement Logout Functionality
- Invalidate tokens on logout by maintaining a token blacklist.
- Monitor and Log Activity
- Use tools like Winston or Morgan to log authentication-related events.
- Regularly Update Dependencies
- Keep all libraries and frameworks up to date to address known vulnerabilities.
Understanding JSON Web Tokens in Depth
A JSON Web Token is a compact, URL-safe string made up of three Base64URL-encoded parts separated by dots: the header, the payload, and the signature. The header declares the token type and the signing algorithm — always HS256 (HMAC-SHA256) or RS256 (RSA) in production systems. The payload carries claims — key-value pairs about the subject, such as the user’s database ID, issued-at timestamp, and expiry time. The signature ties everything together: it is computed from the encoded header and payload using a secret key, so any tampering with the payload will invalidate the signature.
This design makes JWTs self-contained. The server does not need to query a database to verify a token — it only needs the secret key. That property makes JWTs attractive for stateless microservice architectures where storing session state in a shared data store adds operational complexity. However, the same property means that a stolen JWT remains valid until it expires. There is no way to “revoke” a standard JWT mid-flight; the best you can do is keep the expiry short and rely on refresh-token rotation to detect token theft.
There are several claims you should always include in your access tokens. The iss (issuer) claim identifies the service that issued the token, which is important in systems where multiple services issue tokens. The aud (audience) claim specifies which service is supposed to consume the token, preventing an access token issued for service A from being used against service B. The sub (subject) claim holds the user’s unique identifier. The iat (issued at) and exp (expiration) claims enforce the token’s lifetime. Always verify all of these on the receiving side, not just the signature.
One misconception worth clearing up: the payload of a JWT is encoded, not encrypted. Anyone who intercepts the token can decode the base64 payload and read its contents. Never place sensitive information like passwords, credit card numbers, or PII in a JWT payload unless you encrypt the token (producing a JWE rather than a JWS). For an access token, the user ID and a set of role claims is all you typically need.
Key size matters when using asymmetric algorithms. RS256 uses an RSA key pair: the server signs with the private key, and any service can verify with the public key. This is useful in microservice architectures where multiple services need to verify tokens without sharing a secret. The minimum recommended RSA key size is 2048 bits; 4096 bits provides stronger security at the cost of slightly higher CPU overhead. For most applications using HS256, a 256-bit (32-byte) randomly generated secret is sufficient and the performance overhead is negligible. Make sure you generate the secret with a cryptographically secure random number generator, not a human-chosen passphrase — short or predictable secrets are trivially vulnerable to offline brute-force attacks where the attacker replays captured tokens to test candidate keys.
The Security Implications of Session Design Choices
When you sit down to design a login system, you are really making a series of interconnected decisions about where state lives, how long it persists, and who is allowed to invalidate it. Each choice carries security trade-offs that will follow you for the lifetime of the application.
The most fundamental decision is whether to use server-side sessions (where the server stores session state and the client stores only an opaque session ID) or client-side tokens (where all state is encoded in a JWT that the client presents on every request). Server-side sessions are easier to revoke — you just delete the session record — but they require shared storage (Redis, a database) that scales horizontally. Client-side JWTs scale effortlessly but cannot be revoked without building a denylist, which re-introduces the shared state you were trying to avoid in the first place.
A practical middle ground, and the approach taken in this article, is to use short-lived access tokens that live in memory alongside long-lived refresh tokens stored in HTTP-only cookies. Access tokens expire in 15 minutes, so a stolen one has a narrow window of opportunity. Refresh tokens are longer-lived but are stored in HTTP-only cookies that JavaScript cannot read, making XSS exploitation far more difficult. When a refresh token is used, it is immediately rotated: the old one is invalidated and a new one is issued. If an attacker steals a refresh token and uses it before the legitimate user does, the next legitimate use will fail — because the stored hash in the database will no longer match — which signals a possible theft and allows you to invalidate the entire session family.
The expiry duration you choose for your access token is a direct function of how quickly you need to be able to revoke access and how much latency you are willing to accept from the silent refresh mechanism. Fifteen minutes is a widely accepted default for web applications. For high-security applications such as banking or healthcare, two to five minutes is more appropriate. For low-risk consumer apps where seamless UX is important, you might extend to thirty minutes but should never go beyond an hour.
Understanding these trade-offs helps you design a system that is both secure and operationally maintainable. The worst outcome is a system designed without thinking through these choices, where tokens never expire, refresh tokens are not rotated, and there is no mechanism to revoke a compromised session.
Password Hashing: Why bcrypt, and How to Configure It
Passwords must never be stored in plaintext or using a fast hashing algorithm like MD5, SHA-1, or even SHA-256. These algorithms are designed for speed, which is exactly what you don’t want when hashing passwords: speed makes them vulnerable to brute-force and dictionary attacks. An attacker who obtains your database and a modern GPU can test billions of SHA-256 hashes per second.
bcrypt is purpose-built for password storage. It is deliberately slow, and its work factor (also called the cost factor) is configurable. The work factor is an exponent: a bcrypt cost factor of 12 requires $2^12$ (4,096) iterations of the underlying Blowfish cipher, while a cost factor of 13 requires $2^13$ (8,192) — twice as slow. This means you can increase the cost factor as hardware improves, keeping pace with attackers without changing the algorithm. The bcrypt hash also incorporates a random 128-bit salt that is embedded in the output, which makes precomputed rainbow-table attacks useless.
The right cost factor for your application depends on the hardware you are running and the user experience you want to deliver. You should target a bcrypt duration of about 100-300 milliseconds on your production servers. If it is faster than that, increase the cost factor; if it is slower, reduce it. Typical values for modern VMs range from 11 to 13. You can measure on your target hardware like this:
import bcrypt from 'bcrypt'
for (let rounds = 10; rounds <= 14; rounds++) {
const start = Date.now()
await bcrypt.hash('benchmark-password', rounds)
console.log(`Cost ${rounds}: ${Date.now() - start}ms`)
}
Two alternatives to bcrypt are worth knowing about. Argon2 won the 2015 Password Hashing Competition and is considered the state-of-the-art algorithm: it is memory-hard, meaning it requires a configurable amount of RAM to compute, which makes GPU-based attacks even more expensive. Node.js has the argon2 package available, and OWASP now recommends it over bcrypt for new applications. scrypt is another memory-hard alternative that has been available longer and has a solid track record. If you are starting a new project, Argon2id is the best choice; if you are maintaining an existing bcrypt codebase, bcrypt at a cost factor of 12 or higher remains perfectly acceptable.
Defending Against Credential-Based Attacks
Modern attackers rarely try to guess passwords one at a time. The three most common large-scale attacks against login systems are brute force, credential stuffing, and password spraying. Understanding each one helps you deploy the right defences.
Brute force attacks target a single account by trying every possible password combination. They are typically the least scalable attack because the account lockout mechanism or rate limiter stops them quickly. An account-based lockout — locking the account after five failed attempts rather than blocking the IP address — is more effective, because attackers routinely rotate IP addresses but cannot change the target username. However, account lockout can also be weaponised as a denial-of-service attack against your users, so implement exponential backoff (doubling the lockout duration after each threshold) rather than permanent locking on first trigger.
Credential stuffing is a far more dangerous attack. Billions of username/password pairs from previous data breaches are freely available on the dark web. Attackers load these pairs into automated tools and test them against new targets at scale, relying on the fact that a significant percentage of users reuse passwords across services. According to data from the Identity Theft Resource Center, there were over 3,200 publicly disclosed data breaches in 2023 alone, each one feeding the credential stuffing ecosystem. The best defences are: requiring strong, unique passwords (and using a password strength meter to nudge users towards them); integrating the HaveIBeenPwned API to block passwords that appear in known breach databases; and deploying MFA, which renders stolen passwords useless without the second factor.
Password spraying flips the brute force model: instead of trying many passwords against one account, the attacker tries a small number of very common passwords (like Password1! or Summer2023) against a large number of accounts. Because few attempts are made against each account, account lockout thresholds are never triggered. The defence here is enforcing minimum password strength rules that reject common passwords, combined with anomaly monitoring that detects when the same small set of passwords is tried across thousands of accounts in a short time window.
Rate limiting is your first line of defence against all three attack types. The express-rate-limit package provides configurable middleware, but for authentication endpoints the default settings may be too generous. Consider a tighter configuration for login — for example, ten attempts per 15-minute window per IP address — combined with a separate per-account attempt counter stored in your database or Redis. Distributing the rate limit across both IP and account protects against distributed brute force (where each IP sends only one attempt) as well as mass credential stuffing.
Logging and Monitoring Authentication Events
Logging is the mechanism by which you discover that an attack is in progress or has already succeeded. Without meaningful logs, a breach may go undetected for months — the 2023 IBM Cost of a Data Breach report found that the average time to identify and contain a breach was 277 days. Authentication events are among the most important things to log.
Log every failed login attempt — including the email address that was tried (but never the password), the source IP, the user agent, and the timestamp. Log every successful login as well. Log token refresh events, password change events, MFA setup and teardown, and account lockout triggers. These logs form a timeline that lets you reconstruct an attack after the fact and set up real-time alerts for anomalous behaviour.
Be careful about what you log. Never log passwords or raw tokens. If you log request bodies for debugging, make sure your logger sanitizes authentication endpoints. Log redaction middleware can help:
import morgan from 'morgan'
// Custom token that strips the password field from POST body logs
morgan.token('safe-body', (req) => {
if (!req.body) return ''
const sanitized = { ...req.body }
if (sanitized.password) sanitized.password = '[REDACTED]'
if (sanitized.token) sanitized.token = '[REDACTED]'
return JSON.stringify(sanitized)
})
app.use(morgan(':method :url :status :response-time ms - body: :safe-body'))
Structured logging with a library like Winston lets you ship logs to a centralised aggregation service (Datadog, Splunk, or the ELK Stack). Once your logs are centralised, you can write alerts for patterns such as more than fifty failed login attempts in a five-minute window from the same IP, a single account receiving more than ten failed attempts in an hour, or a successful login from a geographic location the account has never used before. These alerts can trigger automatic protective measures — such as requiring MFA for that session — without waiting for a human to review the logs.
Comparing Authentication Libraries for Node.js
If you are building authentication from scratch, it helps to know what established libraries are available and when you might want to reach for one over another.
| Library | Approach | Best For | Drawbacks |
|---|---|---|---|
jsonwebtoken | Raw JWT signing/verification | Custom auth flows where you control everything | No session management, no user model — you build all of that yourself |
passport.js | Strategy-based auth middleware | Adding multiple login methods (local, Google, GitHub, SAML) to an Express app | Complex configuration; requires additional plugins for sessions and JWTs |
express-openid-connect | OIDC middleware for Auth0 | Delegating auth to Auth0 or another OIDC-compliant identity provider | Ties you to Auth0’s ecosystem; less useful for fully custom implementations |
better-auth | Full-stack auth framework | Rapid setup with TypeScript projects | Relatively new; fewer community resources than Passport |
lucia | Session management library | Type-safe server-side session handling in TypeScript | Does not handle OAuth out of the box |
For this article’s use case — a custom React and Node.js stack where you control the database and want to understand every moving part — the jsonwebtoken + bcrypt + express-rate-limit combination gives you the most educational transparency. In a production application at scale, delegating authentication to a managed identity platform (Auth0, AWS Cognito, Clerk, or Firebase Auth) reduces operational burden significantly and shifts the security responsibility for token issuance and management to a vendor that specialises in it.
The decision between building and buying authentication is, at its core, a risk management decision. Building gives you complete control, deep understanding, and zero vendor dependency — but it also means you are responsible for every CVE in every library you use, every edge case in your token rotation logic, and every compliance requirement your industry imposes. Buying from a managed idP offloads most of that responsibility, but introduces a dependency on a third party’s uptime and pricing decisions. Many teams start with a custom implementation to understand the fundamentals (as this article teaches), then migrate to a managed provider once they have paying customers whose data they cannot afford to put at risk.
Deploying Your Secure Login System to Production
Development and production environments have different security requirements. Several hardening steps should be applied before your login system handles real user data.
Environment variables must never be committed to version control. Your JWT_ACCESS_SECRET and JWT_REFRESH_SECRET values should be long, randomly generated strings (at least 256 bits of entropy). Store them in your hosting platform’s secrets manager — AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, or Netlify/Vercel environment variable vaults — and inject them into the runtime environment. Generate secrets with a cryptographically secure method:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
HTTPS is non-negotiable in production. All login traffic must travel over TLS. Without HTTPS, credentials and tokens are transmitted in plaintext and can be intercepted by a network attacker. If you are deploying to a cloud provider with a load balancer, TLS termination typically happens at the load balancer. If you are running your own server, use Certbot with Let’s Encrypt to obtain a free TLS certificate. Configure HTTP Strict Transport Security (HSTS) via the helmet library to instruct browsers to always use HTTPS for your domain, even if the user types http://.
Database security matters as much as the authentication layer. Ensure your MongoDB or PostgreSQL instance is not publicly accessible from the internet. Use least-privilege database credentials — your application’s database user should only have permission to read and write the collections it needs, not to drop databases or manage users. Enable authentication on the database itself, and rotate credentials regularly.
Production rate limits should be tighter than development. Consider using a Redis-backed rate limiter with rate-limit-redis so that limits are shared across multiple application instances, preventing an attacker from bypassing per-process rate limits by targeting different servers:
import rateLimit from 'express-rate-limit'
import RedisStore from 'rate-limit-redis'
import { createClient } from 'redis'
const redisClient = createClient({ url: process.env.REDIS_URL })
await redisClient.connect()
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
message: { message: 'Too many login attempts. Please try again in 15 minutes.' }
})
app.post('/login', loginLimiter, loginHandler)
Finally, run a dependency audit before deploying and schedule regular audits thereafter. npm audit or pnpm audit will flag known vulnerabilities in your dependency tree. Pair this with a tool like Dependabot or Renovate to receive automated pull requests when security patches are released for packages you depend on. The JavaScript ecosystem moves quickly, and a library that was safe when you installed it may have a published CVE six months later.
Future Trends in Secure Login Systems
- Biometric Authentication
-
Use fingerprint or facial recognition for enhanced security.
-
Use fingerprint or facial recognition for enhanced security.
- Passwordless Authentication
- Implement magic links or one-time passwords to eliminate the need for static credentials.
- Zero-Trust Principles
- Continuously verify user identity and access levels.
Understanding CSRF and SameSite Cookies
Cross-Site Request Forgery (CSRF) is an attack that tricks an authenticated user’s browser into making an unintended request to your API. Because the browser automatically sends cookies with cross-origin requests, any page on the internet can potentially trigger actions on behalf of your logged-in users — such as changing their email address or initiating a fund transfer.
The way CSRF exploits HTTP-only cookies is subtle. An attacker hosts a page with a hidden form or JavaScript fetch call that targets your API. When the victim visits that page while logged into your app, their browser includes the session cookie automatically. Your server sees a valid cookie and processes the request, unaware that it was initiated by a third-party page the user didn’t intentionally interact with.
The most robust first line of defence against CSRF for modern applications is the SameSite cookie attribute. Setting SameSite=Strict prevents the browser from including the cookie in any cross-site request whatsoever — the user must navigate directly to your domain, not arrive via a redirect from another site. This effectively eliminates most CSRF attack vectors for standard web applications. However, Strict can break OAuth flows where the identity provider redirects back to your site, so many teams choose SameSite=Lax instead, which allows top-level navigation cross-site but blocks cookies in cross-site subresource requests.
For endpoints that change state (POST, PUT, DELETE, PATCH), you should also implement the Synchroniser Token Pattern as a defence-in-depth measure. The server generates a random CSRF token, embeds it in a cookie that JavaScript can read (note: this is intentional — unlike the refresh token cookie), and requires that same value to be present in a request header on every state-changing request. An attacker’s page cannot read your app’s cookies due to the Same-Origin Policy, so they cannot replicate the token in the header.
The key insight is that the CSRF defence model for REST APIs is different from traditional form-based applications. If your API only accepts Content-Type: application/json, many CSRF vectors are already blocked because HTML forms cannot set custom content types. Verifying the Origin or Referer header is an additional check you can add at the middleware level. When you combine SameSite=Strict cookies with a properly configured CORS policy that only allows your React app’s origin, CSRF risk is dramatically reduced without the need for a traditional CSRF token mechanism.
React Form Security and Client-Side Validation
The client-side form in your login UI is the user’s first point of contact with your authentication system. While server-side validation is the authoritative security control, thoughtful client-side validation improves usability and catches obvious errors before a round trip to the server. The two concerns must be treated separately: client-side validation exists for user experience, and server-side validation exists for security.
A common mistake is writing validation logic only in the React component and assuming it protects the server. It does not — an attacker can disable JavaScript, use a browser devtools network tab, or write a script that posts directly to your API endpoint, bypassing the UI entirely. Your Node.js backend must always validate inputs independently, regardless of what the frontend enforces.
On the client, use a form library like React Hook Form combined with a schema validator like Zod to get a clean, declarative validation pipeline:
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(1, 'Password is required')
})
export default function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm({
resolver: zodResolver(loginSchema)
})
const onSubmit = async (data) => {
try {
await login(data.email, data.password)
} catch {
// surface server errors without revealing sensitive details
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor='email'>Email</label>
<input
id='email'
type='email'
autoComplete='email'
aria-describedby={errors.email ? 'email-error' : undefined}
{...register('email')}
/>
{errors.email && (
<span id='email-error' role='alert'>
{errors.email.message}
</span>
)}
</div>
<div>
<label htmlFor='password'>Password</label>
<input
id='password'
type='password'
autoComplete='current-password'
{...register('password')}
/>
{errors.password && <span role='alert'>{errors.password.message}</span>}
</div>
<button type='submit' disabled={isSubmitting}>
{isSubmitting ? 'Signing in…' : 'Sign In'}
</button>
</form>
)
}
Several HTML attributes deserve special attention here. The autoComplete attribute helps password managers work correctly — autoComplete='email' and autoComplete='current-password' are the correct values for a login form. This matters for security because users with password managers are more likely to use unique, strong passwords for each site. The noValidate attribute on the form disables the browser’s native validation so that your custom Zod-based validation provides a consistent experience across browsers. The role='alert' on error messages ensures that screen readers announce validation errors, making your login accessible to users of assistive technology.
The disabled state during submission prevents double-submitting, which is a subtle but real problem: a user who double-clicks the submit button could inadvertently send two login requests, potentially triggering your rate limiter prematurely. Disabling the button during the async operation eliminates this risk at zero cost.
Passkeys: The Future of Passwordless Authentication
Passkeys represent the most significant shift in consumer authentication in decades. Built on the WebAuthn and FIDO2 standards, passkeys replace the password with a public/private key pair tied to the user’s device. The private key never leaves the device; it is stored in the secure enclave of a smartphone, YubiKey, or platform authenticator like Windows Hello or macOS Touch ID. Authentication works via a cryptographic challenge-response: the server sends a random challenge, the device signs it with the private key, and the server verifies the signature using the stored public key. There is no shared secret to steal from the server side.
From a security standpoint, passkeys eliminate the most common attack vectors entirely: there is no password to phish, no credential to stuff, and no hash to crack. The key is bound to the specific website’s origin, so even a convincing phishing page cannot harvest it — the device will refuse to sign for any origin other than the legitimate one. Apple, Google, and Microsoft have all committed to supporting passkeys natively, and as of 2024 passkeys are available on every major platform: iOS, Android, macOS, Windows, and via hardware tokens.
From a development perspective, adding passkeys to an Express application requires implementing the WebAuthn protocol, which involves a registration ceremony (creating the key pair) and an authentication ceremony (signing a challenge). The @simplewebauthn/server library abstracts the complexity of this protocol for Node.js:
npm install @simplewebauthn/server @simplewebauthn/browser
The registration and authentication flows involve three round trips between the client and server, but the library handles most of the cryptographic details. The important developer obligations are: generating a random challenge per ceremony (never reuse challenges), storing the credential ID and public key per user in your database, and verifying the origin and relying party ID on every authentication attempt.
Passkeys are not yet universally adopted, and many users will still expect a password option for some time. The practical approach for most applications today is to offer passkeys as an option alongside traditional password login — analogous to how “Sign in with Google” coexists with email/password. Over time, as user familiarity grows and device support becomes ubiquitous, you can phase out passwords entirely. The architecture introduced earlier in this article — short-lived access tokens, refresh token rotation, HTTP-only cookies — remains relevant in a passkeys world because you still need to manage the session after authentication, regardless of how the user authenticated in the first place.
Conclusion
Creating a secure login system involves more than just implementing authentication endpoints. Every decision you make — how you hash passwords, where you store tokens, how you handle errors, how you test the flow, and how you monitor for abuse — is a security decision with real consequences for the people trusting you with their data.
The implementation in this article gives you a solid foundation: bcrypt-hashed passwords, short-lived JWTs stored in memory, refresh tokens in HTTP-only cookies with rotation, server-side input validation, rate limiting, generic error messages that prevent user enumeration, and a suite of integration tests that verify the security properties hold. Each of these controls addresses a specific, real-world attack vector, and each one is easy to verify with the testing patterns shown above.
Security is not a feature you add at the end of a project — it is a property you design into every layer from the beginning. By understanding the why behind each control, you are better equipped to evaluate new libraries, adapt to new attack techniques, and make informed trade-offs as your application grows. The codebase you build today sets the foundation that every future feature, every third-party integration, and every new team member will build on top of — getting the authentication layer right from the start is one of the highest-leverage investments you can make. Stay current with OWASP guidelines and NIST SP 800-63B, keep your dependencies patched, and treat your authentication logs as a first-class monitoring surface. Start building secure systems today to ensure the safety of your application and its users.