Skip to content

User Registration and Login Endpoints

The auth endpoints are the entry point to Bulletin. Every user starts here. Building them well means getting registration, login, and token issuance right.

src/routes/auth.ts
import { Router } from 'express'
import { register, login } from '../controllers/auth.js'
import { requireFields } from '../middleware/validate.js'
import rateLimit from 'express-rate-limit'
const router = Router()
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 10,
message: { error: 'Too many attempts, try again in an hour' },
})
router.post('/register', authLimiter, requireFields('username', 'password'), register)
router.post('/login', authLimiter, requireFields('username', 'password'), login)
export default router
src/controllers/auth.ts
import { Request, Response } from 'express'
import bcrypt from 'bcrypt'
import { db } from '../db/index.js'
import { signToken } from '../utils/jwt.js'
const SALT_ROUNDS = 12
export async function register(req: Request, res: Response) {
const { username, password } = req.body as { username: string; password: string }
// Validate username format
if (!/^[a-zA-Z0-9_]{3,30}$/.test(username)) {
return res.status(400).json({
error: 'Username must be 3–30 characters, letters/numbers/underscores only'
})
}
// Validate password strength
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' })
}
// Check if username is taken
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username)
if (existing) {
return res.status(409).json({ error: 'Username already taken' })
}
// Hash the password
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
// Create the user
const result = db.prepare(
'INSERT INTO users (username, password_hash) VALUES (?, ?)'
).run(username, passwordHash)
const userId = result.lastInsertRowid as number
// Issue JWT
const token = signToken({ userId, username })
res.status(201).json({ token, userId, username })
}
export async function login(req: Request, res: Response) {
const { username, password } = req.body as { username: string; password: string }
// Find user
const user = db.prepare(
'SELECT id, username, password_hash FROM users WHERE username = ?'
).get(username) as { id: number; username: string; password_hash: string } | undefined
// Use same message for both "user not found" and "wrong password"
const invalid = () => res.status(401).json({ error: 'Invalid username or password' })
if (!user) return invalid()
// Verify password
const isValid = await bcrypt.compare(password, user.password_hash)
if (!isValid) return invalid()
// Issue JWT
const token = signToken({ userId: user.id, username: user.username })
res.json({ token, userId: user.id, username: user.username })
}
Terminal window
# Register
curl -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "securepassword"}'
# → { "token": "eyJ...", "userId": 1, "username": "alice" }
# Login
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "securepassword"}'
# → { "token": "eyJ...", "userId": 1, "username": "alice" }
# Wrong password
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "wrongpassword"}'
# → 401 { "error": "Invalid username or password" }

The React frontend stores the token in localStorage or React state and includes it in subsequent requests:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

The API verifies this token with the authenticate middleware — no session table lookup needed.

  1. Create src/routes/auth.ts and src/controllers/auth.ts.
  2. Mount the auth router in src/index.ts.
  3. Test registration: create two users. Confirm the second registration with the same username returns 409.
  4. Test login: correct credentials → 200 with token, wrong password → 401.
  5. Decode the returned token at jwt.io — inspect the payload.
  • POST /auth/register hashes the password, inserts the user, returns a JWT.
  • POST /auth/login finds the user, verifies the password with bcrypt, returns a JWT.
  • Both endpoints use the same error message for “not found” and “wrong password”.
  • Rate limit auth endpoints — 10 attempts per hour prevents brute force.
  • Input validation (username format, password length) belongs in the controller, not just the database.