[{"data":1,"prerenderedAt":338},["ShallowReactive",2],{"blog-how-to/secure-login-form":3},{"id":4,"title":5,"body":6,"category":318,"date":319,"dateModified":320,"description":321,"draft":322,"extension":323,"faq":324,"featured":322,"headerVariant":325,"image":324,"keywords":324,"meta":326,"navigation":327,"ogDescription":328,"ogTitle":324,"path":329,"readTime":324,"schemaOrg":330,"schemaType":331,"seo":332,"sitemap":333,"stem":334,"tags":335,"twitterCard":336,"__hash__":337},"blog/blog/how-to/secure-login-form.md","How to Build a Secure Login Form",{"type":7,"value":8,"toc":303},"minimark",[9,13,17,21,27,30,43,48,51,55,86,99,112,125,138,151,183,187,221,225,230,233,237,240,244,247,266,285],[10,11],"category-badge",{"category":12},"How-To Guide",[14,15,5],"h1",{"id":16},"how-to-build-a-secure-login-form",[18,19,20],"p",{},"The complete guide to authentication security",[22,23,24],"tldr",{},[18,25,26],{},"TL;DR (25 minutes):\nUse 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.",[18,28,29],{},"Prerequisites:",[31,32,33,37,40],"ul",{},[34,35,36],"li",{},"HTTPS enabled on your domain",[34,38,39],{},"Database with users table",[34,41,42],{},"Basic frontend and backend setup",[44,45,47],"h2",{"id":46},"why-this-matters","Why This Matters",[18,49,50],{},"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.",[44,52,54],{"id":53},"step-by-step-guide","Step-by-Step Guide",[56,57,59,64,75],"step",{"number":58},"1",[60,61,63],"h3",{"id":62},"create-the-secure-html-form","Create the secure HTML form",[65,66,71],"pre",{"className":67,"code":69,"language":70},[68],"language-text","\u003C!-- Always use HTTPS -->\n\u003Cform\n  action=\"/api/auth/login\"\n  method=\"POST\"\n  autocomplete=\"on\"\n>\n  \u003C!-- CSRF token (populated by server) -->\n  \u003Cinput type=\"hidden\" name=\"_csrf\" value=\"{{csrfToken}}\" />\n\n  \u003Cdiv>\n    \u003Clabel for=\"email\">Email\u003C/label>\n    \u003Cinput\n      type=\"email\"\n      id=\"email\"\n      name=\"email\"\n      required\n      autocomplete=\"email\"\n      inputmode=\"email\"\n    />\n  \u003C/div>\n\n  \u003Cdiv>\n    \u003Clabel for=\"password\">Password\u003C/label>\n    \u003Cinput\n      type=\"password\"\n      id=\"password\"\n      name=\"password\"\n      required\n      autocomplete=\"current-password\"\n      minlength=\"8\"\n    />\n  \u003C/div>\n\n  \u003Cbutton type=\"submit\">Sign In\u003C/button>\n\u003C/form>\n","text",[72,73,69],"code",{"__ignoreMap":74},"",[18,76,77,78,81,82,85],{},"Key attributes: ",[72,79,80],{},"autocomplete"," helps password managers, ",[72,83,84],{},"method=\"POST\""," keeps credentials out of URLs.",[56,87,89,93],{"number":88},"2",[60,90,92],{"id":91},"implement-the-backend-authentication","Implement the backend authentication",[65,94,97],{"className":95,"code":96,"language":70},[68],"import bcrypt from 'bcrypt';\nimport { z } from 'zod';\n\nconst loginSchema = z.object({\n  email: z.string().email().toLowerCase(),\n  password: z.string().min(1)\n});\n\nasync function login(req, res) {\n  // Validate input\n  const result = loginSchema.safeParse(req.body);\n  if (!result.success) {\n    return res.status(400).json({ error: 'Invalid input' });\n  }\n\n  const { email, password } = result.data;\n\n  // Check rate limiting first\n  if (await isRateLimited(email, req.ip)) {\n    return res.status(429).json({\n      error: 'Too many attempts. Please try again later.'\n    });\n  }\n\n  // Find user\n  const user = await db.user.findUnique({\n    where: { email }\n  });\n\n  // IMPORTANT: Always use the same response for all failure cases\n  // This prevents user enumeration\n  if (!user) {\n    await recordFailedAttempt(email, req.ip);\n    // Simulate password hash check to prevent timing attacks\n    await bcrypt.compare(password, '$2b$10$dummy.hash.for.timing');\n    return res.status(401).json({ error: 'Invalid credentials' });\n  }\n\n  // Verify password\n  const passwordValid = await bcrypt.compare(password, user.passwordHash);\n\n  if (!passwordValid) {\n    await recordFailedAttempt(email, req.ip);\n    return res.status(401).json({ error: 'Invalid credentials' });\n  }\n\n  // Check if account is locked\n  if (user.lockedUntil && user.lockedUntil > new Date()) {\n    return res.status(401).json({\n      error: 'Account temporarily locked. Please try again later.'\n    });\n  }\n\n  // Clear failed attempts on success\n  await clearFailedAttempts(email);\n\n  // Create session\n  const session = await createSession(user.id, req);\n\n  // Set secure cookie\n  res.cookie('session', session.token, {\n    httpOnly: true,\n    secure: process.env.NODE_ENV === 'production',\n    sameSite: 'lax',\n    maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days\n  });\n\n  return res.json({ success: true });\n}\n",[72,98,96],{"__ignoreMap":74},[56,100,102,106],{"number":101},"3",[60,103,105],{"id":104},"implement-rate-limiting","Implement rate limiting",[65,107,110],{"className":108,"code":109,"language":70},[68],"import { Ratelimit } from '@upstash/ratelimit';\nimport { Redis } from '@upstash/redis';\n\nconst redis = new Redis({\n  url: process.env.UPSTASH_REDIS_URL,\n  token: process.env.UPSTASH_REDIS_TOKEN\n});\n\n// Rate limit per IP\nconst ipLimiter = new Ratelimit({\n  redis,\n  limiter: Ratelimit.slidingWindow(10, '15 m'), // 10 attempts per 15 min\n});\n\n// Rate limit per email (prevent targeting specific accounts)\nconst emailLimiter = new Ratelimit({\n  redis,\n  limiter: Ratelimit.slidingWindow(5, '15 m'), // 5 attempts per 15 min\n});\n\nasync function isRateLimited(email, ip) {\n  const [ipResult, emailResult] = await Promise.all([\n    ipLimiter.limit(ip),\n    emailLimiter.limit(email)\n  ]);\n\n  return !ipResult.success || !emailResult.success;\n}\n\nasync function recordFailedAttempt(email, ip) {\n  // Increment failure counters\n  await redis.incr(`failed:${email}`);\n  await redis.incr(`failed:ip:${ip}`);\n  await redis.expire(`failed:${email}`, 900); // 15 minutes\n  await redis.expire(`failed:ip:${ip}`, 900);\n\n  // Lock account after 5 failed attempts\n  const attempts = await redis.get(`failed:${email}`);\n  if (Number(attempts) >= 5) {\n    await db.user.update({\n      where: { email },\n      data: { lockedUntil: new Date(Date.now() + 15 * 60 * 1000) }\n    });\n  }\n}\n\nasync function clearFailedAttempts(email) {\n  await redis.del(`failed:${email}`);\n}\n",[72,111,109],{"__ignoreMap":74},[56,113,115,119],{"number":114},"4",[60,116,118],{"id":117},"create-secure-sessions","Create secure sessions",[65,120,123],{"className":121,"code":122,"language":70},[68],"import crypto from 'crypto';\n\nasync function createSession(userId, req) {\n  // Generate secure random token\n  const token = crypto.randomBytes(32).toString('hex');\n\n  // Store session with metadata\n  const session = await db.session.create({\n    data: {\n      token: hashToken(token), // Store hashed token\n      userId,\n      userAgent: req.headers['user-agent'],\n      ipAddress: req.ip,\n      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)\n    }\n  });\n\n  return { ...session, token }; // Return unhashed token for cookie\n}\n\nfunction hashToken(token) {\n  return crypto.createHash('sha256').update(token).digest('hex');\n}\n\nasync function validateSession(token) {\n  const hashedToken = hashToken(token);\n\n  const session = await db.session.findUnique({\n    where: { token: hashedToken },\n    include: { user: true }\n  });\n\n  if (!session || session.expiresAt \u003C new Date()) {\n    return null;\n  }\n\n  // Extend session on activity (sliding expiration)\n  await db.session.update({\n    where: { id: session.id },\n    data: { expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) }\n  });\n\n  return session.user;\n}\n",[72,124,122],{"__ignoreMap":74},[56,126,128,132],{"number":127},"5",[60,129,131],{"id":130},"implement-csrf-protection","Implement CSRF protection",[65,133,136],{"className":134,"code":135,"language":70},[68],"import csrf from 'csurf';\nimport cookieParser from 'cookie-parser';\n\n// Setup middleware\napp.use(cookieParser());\napp.use(csrf({ cookie: true }));\n\n// Add token to forms\napp.get('/login', (req, res) => {\n  res.render('login', { csrfToken: req.csrfToken() });\n});\n\n// Or for SPAs, send token in response header\napp.get('/api/csrf-token', (req, res) => {\n  res.json({ csrfToken: req.csrfToken() });\n});\n\n// Frontend: Include token in requests\nasync function login(email, password) {\n  const csrfToken = document.querySelector('input[name=\"_csrf\"]').value;\n\n  const response = await fetch('/api/auth/login', {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-CSRF-Token': csrfToken\n    },\n    body: JSON.stringify({ email, password }),\n    credentials: 'include'\n  });\n\n  return response.json();\n}\n",[72,137,135],{"__ignoreMap":74},[56,139,141,145],{"number":140},"6",[60,142,144],{"id":143},"add-security-headers","Add security headers",[65,146,149],{"className":147,"code":148,"language":70},[68],"// Add these headers to login pages\napp.use('/login', (req, res, next) => {\n  // Prevent caching of login page\n  res.set('Cache-Control', 'no-store, no-cache, must-revalidate');\n  res.set('Pragma', 'no-cache');\n\n  // Prevent clickjacking\n  res.set('X-Frame-Options', 'DENY');\n\n  // Prevent XSS\n  res.set('X-Content-Type-Options', 'nosniff');\n\n  next();\n});\n",[72,150,148],{"__ignoreMap":74},[152,153,154,157],"warning-box",{},[18,155,156],{},"Security Checklist:",[31,158,159,162,165,168,171,174,177,180],{},[34,160,161],{},"Never log passwords, even during debugging",[34,163,164],{},"Use constant-time comparison for tokens (bcrypt does this automatically)",[34,166,167],{},"Always use HTTPS - no exceptions",[34,169,170],{},"Generic error messages prevent user enumeration",[34,172,173],{},"Rate limit by both IP and email/username",[34,175,176],{},"Set secure cookie attributes (httpOnly, secure, sameSite)",[34,178,179],{},"Implement account lockout after failed attempts",[34,181,182],{},"Consider 2FA for sensitive applications",[44,184,186],{"id":185},"how-to-verify-it-worked","How to Verify It Worked",[188,189,190,197,203,209,215],"ol",{},[34,191,192,196],{},[193,194,195],"strong",{},"Test HTTPS:"," Ensure login only works over HTTPS",[34,198,199,202],{},[193,200,201],{},"Test rate limiting:"," Try 10+ login attempts rapidly",[34,204,205,208],{},[193,206,207],{},"Test user enumeration:"," Check that \"user not found\" and \"wrong password\" return identical responses",[34,210,211,214],{},[193,212,213],{},"Check cookies:"," Verify httpOnly, secure, and sameSite flags",[34,216,217,220],{},[193,218,219],{},"Test CSRF:"," Submit form without valid token",[44,222,224],{"id":223},"common-errors-troubleshooting","Common Errors & Troubleshooting",[226,227,229],"h4",{"id":228},"csrf-token-mismatch","CSRF token mismatch",[18,231,232],{},"Ensure cookies are being sent (credentials: 'include') and the token matches between form and cookie.",[226,234,236],{"id":235},"session-not-persisting","Session not persisting",[18,238,239],{},"Check cookie domain, secure flag (must use HTTPS in production), and sameSite settings.",[226,241,243],{"id":242},"password-comparison-always-fails","Password comparison always fails",[18,245,246],{},"Verify the stored hash was created with bcrypt. Check encoding issues with special characters.",[248,249,250,257,260],"faq-section",{},[251,252,254],"faq-item",{"question":253},"Should I use JWTs or sessions for login?",[18,255,256],{},"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.",[18,258,259],{},"::faq-item{question=\"How do I implement \"Remember Me\"?\"}\nUse 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.\n::",[251,261,263],{"question":262},"Should I use magic links instead of passwords?",[18,264,265],{},"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.",[18,267,268,271,276,277,276,281],{},[193,269,270],{},"Related guides:",[272,273,275],"a",{"href":274},"/blog/how-to/hash-passwords-securely","Hash Passwords Securely"," ·\n",[272,278,280],{"href":279},"/blog/how-to/session-management","Session Management",[272,282,284],{"href":283},"/blog/how-to/two-factor-auth","Two-Factor Authentication",[286,287,288,293,298],"related-articles",{},[289,290],"related-card",{"description":291,"href":279,"title":292},"Step-by-step guide to secure session management. Create, store, validate, and expire sessions properly to protect user a","How to Implement Secure Session Management",[289,294],{"description":295,"href":296,"title":297},"Step-by-step guide to configuring CORS in Next.js, Express, and serverless functions. Avoid security mistakes and fix co","/blog/how-to/setup-cors-properly","How to Set Up CORS Properly",[289,299],{"description":300,"href":301,"title":302},"Step-by-step guide to setting up Row Level Security in Supabase. Enable RLS, write policies, test access, and avoid comm","/blog/how-to/setup-supabase-rls","How to Set Up Supabase Row Level Security (RLS)",{"title":74,"searchDepth":304,"depth":304,"links":305},2,[306,307,316,317],{"id":46,"depth":304,"text":47},{"id":53,"depth":304,"text":54,"children":308},[309,311,312,313,314,315],{"id":62,"depth":310,"text":63},3,{"id":91,"depth":310,"text":92},{"id":104,"depth":310,"text":105},{"id":117,"depth":310,"text":118},{"id":130,"depth":310,"text":131},{"id":143,"depth":310,"text":144},{"id":185,"depth":304,"text":186},{"id":223,"depth":304,"text":224},"how-to","2026-01-23","2026-02-02","Step-by-step guide to building a secure login form. Prevent brute force attacks, handle credentials safely, and implement proper session management.",false,"md",null,"yellow",{},true,"Build a secure login form with proper security controls.","/blog/how-to/secure-login-form","[object Object]","HowTo",{"title":5,"description":321},{"loc":329},"blog/how-to/secure-login-form",[],"summary_large_image","o6FDZICCsow_ZTiTvGnRNhpRZNyuI3AYHHLTKaN23os",1775843927701]