Build an Authentication System with AI (Login, Signup, Password Reset)

You ask AI to "add login," and it generates JWT, bcrypt, middleware, protected routes — and you have no idea what any of it does. This project walks through building auth step by step, so you understand every piece. Signup, login, logout, password reset, and protected routes — all explained in plain English.

TL;DR

Build a complete auth system with Node.js and Express — user signup with bcrypt password hashing, login with JWT tokens, middleware-protected routes, and a password reset flow. No frontend needed. Test everything with curl. You'll understand what every line of AI-generated auth code actually does. Stack: Node.js + Express + bcrypt + jsonwebtoken. Time: ~3 hours.

Why Auth Is the Wall Most Vibe Coders Hit

You can build a to-do app, a REST API, even a decent-looking frontend — and then you try to add login. That's where things fall apart.

You ask your AI "add user authentication," and it drops 200 lines of code across five files. There's something called bcrypt hashing passwords. A jsonwebtoken library creating tokens. Middleware functions checking headers. Secret keys, expiration times, protected routes. It all works — until it doesn't. And when it breaks, you have no idea where to even look.

Authentication is the first feature where you can't just let AI generate it and move on. Here's why:

  • Security mistakes are invisible. A bug in your UI is obvious — you can see it. A bug in your auth system means someone can log in as any user, and you'll never know until it's too late.
  • AI gets auth wrong in subtle ways. It might store passwords in plain text. It might use weak token secrets. It might skip rate limiting entirely. The code runs, the tests pass, and you're wide open.
  • Auth touches everything. Once you have login, every route in your app needs to know: is this user logged in? Are they allowed to do this? It's not one feature — it's a layer that wraps your entire app.
  • Debugging auth requires understanding auth. When a user gets a 401 error, you need to know: is the token expired? Was it never sent? Is the middleware checking the right header? You can't debug what you don't understand.

That's why we're building it step by step — not all at once. You'll prompt your AI for one piece at a time, read what it generates, understand what it does, and then move to the next piece.

What You're Building

By the end of this project, you'll have a working auth system with five core features:

  1. Signup — a new user creates an account with email and password. The password gets hashed (scrambled) before storage, so even if someone steals your database, they can't read the passwords.
  2. Login — a user sends their email and password. If they match, the server sends back a JWT token — a digital pass that proves who they are.
  3. Logout — the client throws away the token. (Yes, it's really that simple on the server side.)
  4. Protected routes — certain endpoints require a valid token. No token, no access. This is where middleware comes in.
  5. Password reset — a user who forgot their password gets a temporary reset token, uses it to set a new password.

The stack:

  • Node.js + Express — the server and routing (same as the REST API project)
  • bcrypt — hashes passwords so they're never stored as plain text
  • jsonwebtoken (JWT) — creates and verifies tokens that prove a user is logged in
  • In-memory array — stores users (no database setup needed to learn the concepts)

No frontend needed. You'll test everything with curl from the terminal, just like the REST API project. Auth is a backend concept — understanding it without a UI actually makes it clearer.

The Approach: Prompt AI in Stages, Not All at Once

The biggest mistake vibe coders make with auth is asking for everything at once: "Build me a full authentication system with signup, login, JWT, password reset, and protected routes."

Your AI will generate it. It'll probably even work. But you'll have 300+ lines of code you don't understand, and the first time something breaks, you're stuck.

Instead, we're going to prompt AI in five stages:

  1. User model — what a user looks like in your system
  2. Signup — creating users with hashed passwords
  3. Login — checking passwords and issuing tokens
  4. Protected routes — middleware that checks tokens
  5. Password reset — generating and consuming reset tokens

Each stage gets its own prompt, its own code review, its own test. By the time you're done, you'll understand the complete picture — because you built it one piece at a time.

Step 1: User Model and Database Table

Before anything else, you need to decide what a "user" looks like in your system. In a real app, this would be a database table. We're using an in-memory array, but the shape is the same.

What to Tell AI

"Set up a new Node.js project for an authentication system. Create a folder called auth-system. Run npm init -y, install express, bcrypt, and jsonwebtoken. Create index.js that: imports all three packages, creates an Express app on port 3000, adds express.json() middleware, creates an empty users array to store user objects, adds a health check GET / route that returns JSON { message: 'Auth system running' }, and starts the server. Each user object will eventually have: id, email, password (hashed), and createdAt."

What AI Will Generate

# Terminal commands
mkdir auth-system
cd auth-system
npm init -y
npm install express bcrypt jsonwebtoken
// index.js
const express = require('express')
const bcrypt = require('bcrypt')
const jwt = require('jsonwebtoken')

const app = express()
const PORT = 3000
const JWT_SECRET = 'your-secret-key-change-this-in-production'

// Middleware
app.use(express.json())

// In-memory user store
let users = []
let nextId = 1

// Health check
app.get('/', (req, res) => {
  res.json({ message: 'Auth system running' })
})

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`)
})

Let's talk about what a user needs and why:

  • id — a unique identifier. In a real database, this would be auto-generated. We'll use a counter.
  • email — what the user logs in with. Must be unique — you can't have two accounts with the same email.
  • password — but never the actual password. This field stores the hashed version. The original password is never saved anywhere.
  • createdAt — when the account was created. Useful for auditing and sorting.

About JWT_SECRET: This is the key used to sign tokens. Anyone with this key can create fake tokens. In production, this would be a long random string stored in an environment variable, never in your code. For learning, a placeholder string is fine.

What to Check

node index.js
# "Server running on http://localhost:3000"

curl http://localhost:3000/
# {"message":"Auth system running"}

Step 2: Signup — Hashing Passwords with bcrypt

Signup is where a new user creates their account. The critical thing that happens here: the password gets hashed before it's stored. Let's break down what that means before we write any code.

What bcrypt Does in Plain English

Imagine you write your password on a piece of paper, run it through a shredder, and store the shredded version. If someone steals the shredded paper, they can't reconstruct your password. But if you bring the same password again, you can shred it the same way and compare the results.

That's hashing. bcrypt takes your password (like "mypassword123") and turns it into something like "$2b$10$N9qo8uLOickgx2ZMRZoMye...". This process is:

  • One-way — you can't reverse a hash back into the original password. There's no "unhash" function.
  • Deterministic-ish — the same password produces a different hash each time (because bcrypt adds random "salt"), but bcrypt.compare() can still check if a password matches a hash.
  • Intentionally slow — bcrypt is designed to be slow (the "10" in the hash is the cost factor). That sounds bad, but it means attackers can't rapidly guess millions of passwords.
What to Tell AI

"Add a POST /signup route to index.js. It should: read email and password from req.body, check if a user with that email already exists in the users array (return 409 Conflict if so), validate that both email and password are provided (return 400 if not), validate password is at least 8 characters, hash the password using bcrypt with 10 salt rounds, create a new user object with id, email, hashed password, and createdAt timestamp, push it to the users array, and return the user (without the password field) with status 201."

What AI Will Generate

// POST /signup — create a new user
app.post('/signup', async (req, res) => {
  try {
    const { email, password } = req.body

    // Validate input
    if (!email || !password) {
      return res.status(400).json({ error: 'Email and password are required' })
    }

    if (password.length < 8) {
      return res.status(400).json({ error: 'Password must be at least 8 characters' })
    }

    // Check if user already exists
    const existingUser = users.find(u => u.email === email)
    if (existingUser) {
      return res.status(409).json({ error: 'Email already registered' })
    }

    // Hash the password
    const saltRounds = 10
    const hashedPassword = await bcrypt.hash(password, saltRounds)

    // Create user
    const newUser = {
      id: nextId++,
      email,
      password: hashedPassword,
      createdAt: new Date().toISOString()
    }

    users.push(newUser)

    // Return user without password
    const { password: _, ...userWithoutPassword } = newUser
    res.status(201).json(userWithoutPassword)
  } catch (error) {
    res.status(500).json({ error: 'Server error during signup' })
  }
})

Several things happening here that are worth understanding:

  • async/awaitbcrypt.hash() is an asynchronous operation (it takes time to compute). The async keyword on the function and await before the hash call means "wait for this to finish before moving on."
  • saltRounds = 10 — the "cost factor." Higher numbers make hashing slower, which makes brute-force attacks harder. 10 is the standard recommendation — it takes about 100ms per hash, which is fine for login but brutal for someone trying millions of guesses.
  • status(409) — HTTP 409 Conflict means "what you're trying to create already exists." More specific than a generic 400.
  • const { password: _, ...userWithoutPassword } — this destructuring trick extracts the password field into a throwaway variable _ and spreads everything else into a new object. Never send the hashed password back to the client — not even the hash.

What to Check

# Create a new user
curl -X POST http://localhost:3000/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"securepass123"}'
# → {"id":1,"email":"test@example.com","createdAt":"2026-03-18T..."}

# Try to create the same user again
curl -X POST http://localhost:3000/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"securepass123"}'
# → {"error":"Email already registered"} with 409

# Try with a short password
curl -X POST http://localhost:3000/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"short@example.com","password":"abc"}'
# → {"error":"Password must be at least 8 characters"} with 400

Notice the response doesn't include the password — that's by design. The hashed password lives in the array on the server but never leaves it.

Step 3: Login — JWT Tokens

Now that users can sign up, they need to log in. Login means: the user sends their email and password, the server checks if they match, and if they do, sends back a JWT token.

What a JWT Token Is and Why It Exists

A JWT (JSON Web Token) is like a wristband at a concert. When you enter (log in), security checks your ticket and gives you a wristband. After that, you just flash the wristband to get into any area — you don't need to show your ticket again every time.

The token is a long string that looks like this: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImlhdCI6MTcxMDc1...

It has three parts separated by dots:

  • Header — says "this is a JWT and here's the algorithm used to sign it"
  • Payload — the actual data (user ID, email, expiration time). This is not encrypted — anyone can decode it. Don't put secrets in here.
  • Signature — a cryptographic stamp created with your JWT_SECRET. This proves the token wasn't tampered with. If anyone changes the payload, the signature won't match.
What to Tell AI

"Add a POST /login route to index.js. It should: read email and password from req.body, validate both are provided, find the user by email in the users array (return 401 if not found), use bcrypt.compare to check the password against the stored hash (return 401 with 'Invalid credentials' if wrong — don't say whether the email or password was wrong), generate a JWT token using jwt.sign with the user's id and email as payload, JWT_SECRET as the key, and expiresIn set to '1h', and return the token with status 200."

What AI Will Generate

// POST /login — authenticate and get a token
app.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body

    if (!email || !password) {
      return res.status(400).json({ error: 'Email and password are required' })
    }

    // Find user
    const user = users.find(u => u.email === email)
    if (!user) {
      return res.status(401).json({ error: 'Invalid credentials' })
    }

    // Check password
    const passwordMatch = await bcrypt.compare(password, user.password)
    if (!passwordMatch) {
      return res.status(401).json({ error: 'Invalid credentials' })
    }

    // Generate JWT
    const token = jwt.sign(
      { userId: user.id, email: user.email },
      JWT_SECRET,
      { expiresIn: '1h' }
    )

    res.status(200).json({
      message: 'Login successful',
      token
    })
  } catch (error) {
    res.status(500).json({ error: 'Server error during login' })
  }
})

Important details:

  • Same error for wrong email AND wrong password — both return "Invalid credentials." This is intentional security. If you said "email not found" vs. "wrong password," an attacker could figure out which emails have accounts.
  • bcrypt.compare() — takes the plain password and the stored hash, and returns true/false. It handles the salt internally — you don't need to extract or store the salt separately.
  • expiresIn: '1h' — the token is valid for 1 hour. After that, the user needs to log in again. Short expiration limits the damage if a token is stolen.
  • The token payload contains userId and email — this is what the protected routes will read to know who's making the request.

Where does the client store the token? That's a frontend concern. Common approaches: localStorage (simple but vulnerable to XSS), HTTP-only cookies (more secure, harder to set up), or in-memory (safest, but lost on page refresh). For this tutorial, you'll just copy the token from the curl response and paste it into the next request.

What to Check

# Login with correct credentials
curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"securepass123"}'
# → {"message":"Login successful","token":"eyJhbGciOi..."}

# Copy that token — you'll need it for Step 4!

# Try wrong password
curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"wrongpassword"}'
# → {"error":"Invalid credentials"} with 401

# Try non-existent email
curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"nobody@example.com","password":"securepass123"}'
# → {"error":"Invalid credentials"} with 401

Save that token from the successful login — you'll paste it into the next step's requests.

Step 4: Protected Routes — Middleware That Checks Tokens

Now you have users who can sign up and log in. The next question is: how do you restrict certain routes to logged-in users only? That's where middleware comes in.

What Middleware Does Here

Middleware is code that runs between the request arriving and your route handler running. For auth, the middleware's job is simple:

  1. Check if the request has a token in the Authorization header
  2. Verify the token is valid and not expired
  3. If valid, attach the user info to the request and let it through
  4. If invalid or missing, block the request with a 401 error
What to Tell AI

"Add an authentication middleware function called authenticateToken to index.js. It should: read the Authorization header from the request, check if it starts with 'Bearer ' (return 401 if missing), extract the token after 'Bearer ', use jwt.verify with JWT_SECRET to decode it (return 403 if invalid or expired), attach the decoded payload to req.user, and call next() to pass to the route handler. Then add a GET /profile route that uses this middleware and returns the current user's info (id, email, createdAt) from the users array based on req.user.userId."

What AI Will Generate

// Authentication middleware
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization']

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Access token required' })
  }

  const token = authHeader.split(' ')[1]

  try {
    const decoded = jwt.verify(token, JWT_SECRET)
    req.user = decoded
    next()
  } catch (error) {
    return res.status(403).json({ error: 'Invalid or expired token' })
  }
}

// GET /profile — protected route (requires valid token)
app.get('/profile', authenticateToken, (req, res) => {
  const user = users.find(u => u.id === req.user.userId)

  if (!user) {
    return res.status(404).json({ error: 'User not found' })
  }

  const { password: _, ...userWithoutPassword } = user
  res.status(200).json(userWithoutPassword)
})

Here's what's happening step by step:

  • req.headers['authorization'] — HTTP headers are metadata sent with every request. The Authorization header is the standard place for tokens. The format is Bearer <token> — the word "Bearer" followed by a space and the actual token.
  • authHeader.split(' ')[1] — splits "Bearer eyJhbG..." on the space and takes the second part (the token itself).
  • jwt.verify() — checks the token's signature and expiration. If the token was tampered with or has expired, this throws an error.
  • req.user = decoded — attaches the token's payload (userId, email) to the request object. Now the route handler can access req.user.userId to know who's making the request.
  • next() — the critical part. This tells Express "this middleware is done, move on to the next function" (the route handler). Without next(), the request hangs forever.
  • app.get('/profile', authenticateToken, ...) — the middleware goes between the path and the handler. Express runs authenticateToken first. If it calls next(), the handler runs. If it sends a response (like 401), the handler never runs.

401 vs 403: Both mean "you can't access this." The difference: 401 Unauthorized means "you didn't provide credentials" (no token). 403 Forbidden means "you provided credentials but they're not valid" (bad or expired token). Using the right code helps clients know whether to redirect to login (401) or show an error (403).

What to Check

# First, sign up and log in to get a token
curl -X POST http://localhost:3000/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"securepass123"}'

curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"securepass123"}'
# Copy the token from the response

# Access profile WITH token (replace YOUR_TOKEN_HERE)
curl http://localhost:3000/profile \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"
# → {"id":1,"email":"test@example.com","createdAt":"..."}

# Try WITHOUT token
curl http://localhost:3000/profile
# → {"error":"Access token required"} with 401

# Try with a garbage token
curl http://localhost:3000/profile \
  -H "Authorization: Bearer this.is.not.a.real.token"
# → {"error":"Invalid or expired token"} with 403

You can reuse this authenticateToken middleware on any route. Just put it in the middle: app.get('/any-route', authenticateToken, handler). That's how you protect your entire app — one middleware function applied wherever you need it.

Step 5: Password Reset Flow

Users forget passwords. It happens. A proper password reset flow works like this:

  1. User says "I forgot my password" and provides their email
  2. Server generates a temporary reset token and (in a real app) emails it to the user
  3. User sends the reset token along with their new password
  4. Server verifies the reset token, hashes the new password, updates the user

We're skipping the email part (that's a whole separate system). Instead, the reset token will be returned in the API response — in production, you'd send it via email.

What to Tell AI

"Add two routes for password reset to index.js. First, POST /forgot-password: read email from req.body, find the user, generate a random reset token using crypto.randomBytes(32).toString('hex'), store the token and an expiration time (1 hour from now) on the user object, and return the token in the response (in production this would be emailed). You'll need to add const crypto = require('crypto') at the top. Second, POST /reset-password: read token and newPassword from req.body, find the user whose resetToken matches and whose resetTokenExpiry is still in the future, validate newPassword is at least 8 characters, hash the new password with bcrypt, update the user's password, clear the reset token fields, and return a success message."

What AI Will Generate

First, add at the top of index.js:

const crypto = require('crypto')  // Built into Node.js, no install needed
// POST /forgot-password — request a reset token
app.post('/forgot-password', (req, res) => {
  const { email } = req.body

  if (!email) {
    return res.status(400).json({ error: 'Email is required' })
  }

  const user = users.find(u => u.email === email)

  // Always return success — don't reveal if email exists
  if (!user) {
    return res.status(200).json({
      message: 'If an account with that email exists, a reset link has been sent'
    })
  }

  // Generate reset token
  const resetToken = crypto.randomBytes(32).toString('hex')
  const resetTokenExpiry = Date.now() + 3600000  // 1 hour from now

  // Store on user object
  user.resetToken = resetToken
  user.resetTokenExpiry = resetTokenExpiry

  // In production: send this token via email, don't return it in the response
  res.status(200).json({
    message: 'If an account with that email exists, a reset link has been sent',
    resetToken  // ← ONLY for testing! Remove in production
  })
})

// POST /reset-password — use token to set new password
app.post('/reset-password', async (req, res) => {
  try {
    const { token, newPassword } = req.body

    if (!token || !newPassword) {
      return res.status(400).json({ error: 'Token and new password are required' })
    }

    if (newPassword.length < 8) {
      return res.status(400).json({ error: 'Password must be at least 8 characters' })
    }

    // Find user with valid, non-expired token
    const user = users.find(
      u => u.resetToken === token && u.resetTokenExpiry > Date.now()
    )

    if (!user) {
      return res.status(400).json({ error: 'Invalid or expired reset token' })
    }

    // Hash new password and update
    const hashedPassword = await bcrypt.hash(newPassword, 10)
    user.password = hashedPassword

    // Clear reset token — it's been used
    delete user.resetToken
    delete user.resetTokenExpiry

    res.status(200).json({ message: 'Password has been reset successfully' })
  } catch (error) {
    res.status(500).json({ error: 'Server error during password reset' })
  }
})

Security details worth understanding:

  • Same response whether email exists or not — the /forgot-password endpoint always says "if an account exists, we sent a link." This prevents attackers from checking which emails have accounts (called email enumeration).
  • crypto.randomBytes(32) — generates 32 bytes of cryptographically secure random data, then converts to a hex string. This is much stronger than Math.random() — attackers can't guess or predict it.
  • Token expiry — the reset token only works for 1 hour. After that, the user has to request a new one. This limits the window if a token is intercepted.
  • One-time use — after the password is reset, the token is deleted. You can't use the same reset link twice.

In production, the reset token would be in a URL. The flow would be: user clicks "Forgot Password," enters their email, receives an email with a link like https://yourapp.com/reset-password?token=abc123, clicks it, enters a new password. The frontend reads the token from the URL and sends it to your /reset-password endpoint. The backend logic you just built stays exactly the same.

What to Check

# Request a password reset
curl -X POST http://localhost:3000/forgot-password \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com"}'
# → {"message":"If an account...","resetToken":"a1b2c3..."}

# Copy the resetToken, then reset the password
curl -X POST http://localhost:3000/reset-password \
  -H "Content-Type: application/json" \
  -d '{"token":"PASTE_RESET_TOKEN_HERE","newPassword":"mynewpassword456"}'
# → {"message":"Password has been reset successfully"}

# Verify: login with old password should fail
curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"securepass123"}'
# → {"error":"Invalid credentials"} with 401

# Login with new password should work
curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"mynewpassword456"}'
# → {"message":"Login successful","token":"eyJhbG..."}

The old password no longer works. The new one does. The reset token has been consumed and can't be reused.

What AI Gets Wrong with Auth

AI coding tools generate working auth code. But "working" and "secure" are two different things. Here are the most common mistakes AI makes — and what you should check every time.

1. Storing Passwords in Plain Text

Some AI-generated code skips hashing entirely, especially if your prompt isn't specific. You'll see code that stores password: req.body.password directly. If someone accesses your database, every user's password is exposed — and since people reuse passwords, that means their bank, email, and social media accounts are all compromised too.

What to check: Search your code for bcrypt.hash. If it's not there, your passwords aren't hashed.

2. Using a Weak or Hardcoded JWT Secret

AI often generates JWT_SECRET = 'secret' or 'your-secret-key'. If an attacker knows (or guesses) your secret, they can forge tokens for any user.

What to check: Your JWT secret should be at least 32 characters of random text, stored in an environment variable: process.env.JWT_SECRET.

3. No Rate Limiting on Login

Without rate limiting, an attacker can try thousands of password combinations per second. AI almost never adds rate limiting unless you explicitly ask for it.

What to add: Use the express-rate-limit package:

const rateLimit = require('express-rate-limit')
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 5,                     // 5 attempts per window
  message: { error: 'Too many login attempts. Try again in 15 minutes.' }
})
app.post('/login', loginLimiter, async (req, res) => { ... })

4. Missing CSRF Protection

If your auth uses cookies (common in server-rendered apps), you need CSRF protection. Without it, a malicious website can make requests on behalf of your logged-in users. JWT in the Authorization header is naturally CSRF-resistant, but cookie-based auth is not.

What to check: If you're using cookies for tokens, add the csurf middleware or use the SameSite cookie attribute.

5. Tokens That Never Expire

Some AI generates tokens without expiresIn, meaning they last forever. A stolen token gives permanent access.

What to check: Every jwt.sign() call should have an expiresIn option. For access tokens, 15 minutes to 1 hour is standard.

6. Revealing Whether an Email Exists

Error messages like "No account found with that email" tell attackers which emails are registered. Login and forgot-password endpoints should give the same response regardless of whether the email exists.

What to check: Both wrong-email and wrong-password should return the identical error message.

Security Checklist

Before you consider your auth system ready — even for a side project — run through this list:

  • ☐ Passwords are hashed with bcrypt (salt rounds ≥ 10)
  • ☐ Plain-text passwords are never stored or logged anywhere
  • ☐ JWT secret is long, random, and stored in an environment variable
  • ☐ Tokens have an expiration time (1 hour or less for access tokens)
  • ☐ Login endpoint returns the same error for wrong email and wrong password
  • ☐ Forgot-password endpoint doesn't reveal whether the email exists
  • ☐ Reset tokens are single-use and time-limited
  • ☐ Password minimum length is enforced (8+ characters)
  • ☐ Rate limiting is enabled on login and forgot-password routes
  • ☐ The hashed password is never returned in API responses
  • ☐ HTTPS is used in production (tokens sent over HTTP can be intercepted)
  • ☐ Error messages don't leak internal details (no stack traces in production)

Ask your AI: "Review my auth code against OWASP authentication best practices and flag any issues." Then cross-reference with this list. If your AI skipped any of these, add them manually.

Your Complete index.js

Here's the full working file with all five features:

const express = require('express')
const bcrypt = require('bcrypt')
const jwt = require('jsonwebtoken')
const crypto = require('crypto')

const app = express()
const PORT = 3000
const JWT_SECRET = 'your-secret-key-change-this-in-production'

// Middleware
app.use(express.json())

// In-memory user store
let users = []
let nextId = 1

// Health check
app.get('/', (req, res) => {
  res.json({ message: 'Auth system running' })
})

// POST /signup — create a new user
app.post('/signup', async (req, res) => {
  try {
    const { email, password } = req.body

    if (!email || !password) {
      return res.status(400).json({ error: 'Email and password are required' })
    }

    if (password.length < 8) {
      return res.status(400).json({ error: 'Password must be at least 8 characters' })
    }

    const existingUser = users.find(u => u.email === email)
    if (existingUser) {
      return res.status(409).json({ error: 'Email already registered' })
    }

    const hashedPassword = await bcrypt.hash(password, 10)

    const newUser = {
      id: nextId++,
      email,
      password: hashedPassword,
      createdAt: new Date().toISOString()
    }

    users.push(newUser)

    const { password: _, ...userWithoutPassword } = newUser
    res.status(201).json(userWithoutPassword)
  } catch (error) {
    res.status(500).json({ error: 'Server error during signup' })
  }
})

// POST /login — authenticate and get a token
app.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body

    if (!email || !password) {
      return res.status(400).json({ error: 'Email and password are required' })
    }

    const user = users.find(u => u.email === email)
    if (!user) {
      return res.status(401).json({ error: 'Invalid credentials' })
    }

    const passwordMatch = await bcrypt.compare(password, user.password)
    if (!passwordMatch) {
      return res.status(401).json({ error: 'Invalid credentials' })
    }

    const token = jwt.sign(
      { userId: user.id, email: user.email },
      JWT_SECRET,
      { expiresIn: '1h' }
    )

    res.status(200).json({ message: 'Login successful', token })
  } catch (error) {
    res.status(500).json({ error: 'Server error during login' })
  }
})

// Authentication middleware
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization']

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Access token required' })
  }

  const token = authHeader.split(' ')[1]

  try {
    const decoded = jwt.verify(token, JWT_SECRET)
    req.user = decoded
    next()
  } catch (error) {
    return res.status(403).json({ error: 'Invalid or expired token' })
  }
}

// GET /profile — protected route
app.get('/profile', authenticateToken, (req, res) => {
  const user = users.find(u => u.id === req.user.userId)

  if (!user) {
    return res.status(404).json({ error: 'User not found' })
  }

  const { password: _, ...userWithoutPassword } = user
  res.status(200).json(userWithoutPassword)
})

// POST /forgot-password — request a reset token
app.post('/forgot-password', (req, res) => {
  const { email } = req.body

  if (!email) {
    return res.status(400).json({ error: 'Email is required' })
  }

  const user = users.find(u => u.email === email)

  if (!user) {
    return res.status(200).json({
      message: 'If an account with that email exists, a reset link has been sent'
    })
  }

  const resetToken = crypto.randomBytes(32).toString('hex')
  user.resetToken = resetToken
  user.resetTokenExpiry = Date.now() + 3600000

  res.status(200).json({
    message: 'If an account with that email exists, a reset link has been sent',
    resetToken  // Remove in production — send via email instead
  })
})

// POST /reset-password — use token to set new password
app.post('/reset-password', async (req, res) => {
  try {
    const { token, newPassword } = req.body

    if (!token || !newPassword) {
      return res.status(400).json({ error: 'Token and new password are required' })
    }

    if (newPassword.length < 8) {
      return res.status(400).json({ error: 'Password must be at least 8 characters' })
    }

    const user = users.find(
      u => u.resetToken === token && u.resetTokenExpiry > Date.now()
    )

    if (!user) {
      return res.status(400).json({ error: 'Invalid or expired reset token' })
    }

    user.password = await bcrypt.hash(newPassword, 10)
    delete user.resetToken
    delete user.resetTokenExpiry

    res.status(200).json({ message: 'Password has been reset successfully' })
  } catch (error) {
    res.status(500).json({ error: 'Server error during password reset' })
  }
})

// 404 — unknown route
app.use((req, res) => {
  res.status(404).json({ error: 'Not found' })
})

// 500 — global error handler
app.use((err, req, res, next) => {
  console.error(err.stack)
  res.status(500).json({ error: 'Internal server error' })
})

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`)
})

What You Learned

You just built the feature that separates toy apps from real ones. Here's what you now understand:

  • Password hashing with bcrypt — why passwords are never stored as text, what salt rounds do, and how bcrypt.compare() checks a password against a hash.
  • JWT tokens — what they contain (header, payload, signature), how they're created with jwt.sign(), how they're verified with jwt.verify(), and why they expire.
  • Auth middleware — a function that sits between the request and the route, checking the Authorization header and blocking unauthorized access.
  • Password reset flow — generating cryptographically secure tokens, enforcing expiration, single-use consumption.
  • Security patterns — same error messages for wrong email/password, never returning hashed passwords, not revealing which emails exist.

The next time you ask AI to "add login," you'll understand every line it generates. When the auth breaks — and it will — you'll know exactly where to look.

Frequently Asked Questions

Can I use this auth system in production?

This tutorial teaches you the core patterns — password hashing with bcrypt, JWT tokens, protected routes, and password reset flows. For a real production app, you'd also want rate limiting on login attempts, refresh tokens (not just access tokens), HTTPS everywhere, CSRF protection, and email verification. The patterns here are correct and production-grade, but a real deployment needs additional security layers. Use this as your foundation, then ask your AI to add each layer one at a time.

What is the difference between authentication and authorization?

Authentication answers "who are you?" — it's the login process where a user proves their identity with a password. Authorization answers "what are you allowed to do?" — it's checking whether a logged-in user has permission to access a specific resource. This tutorial covers authentication. Authorization is the next step — for example, making sure User A can't edit User B's profile.

Why use JWT instead of sessions?

JWTs (JSON Web Tokens) are self-contained — the token itself holds the user's identity, so your server doesn't need to store session data. This makes them simpler for APIs and works well when your frontend and backend are on different domains. Sessions store a simple ID in a cookie and look up the user on each request, which requires server-side storage. Both work. JWTs are more common in modern API-first architectures, which is why most AI tools generate them by default.

How do I add Google or GitHub login to this?

That's called OAuth, and it's a separate layer on top of what you've built here. The core idea: instead of the user creating a password, they click "Login with Google," Google confirms their identity, and sends your app a token proving who they are. You still create a user record in your database — you just skip the password part. Libraries like Passport.js handle the OAuth flow.

What happens if someone steals a JWT token?

They can impersonate that user until the token expires. That's why short expiration times matter — this tutorial uses 1 hour. In production, you'd use short-lived access tokens (15 minutes) paired with longer-lived refresh tokens stored in HTTP-only cookies. If a token is stolen, the damage window is small. You should also use HTTPS to prevent tokens from being intercepted in transit.