📝 User Registration Flow
Registration is the front door to your application — and the first place attackers knock. In this lesson you'll build the full signup pipeline end to end: a client form posts to a server that validates and normalizes the input, checks for an existing account without leaking who's registered, hashes the password with bcrypt, creates the user, kicks off email verification, and responds — never once exposing the password hash.
Week 9 · Day 1 (Monday: Authentication Basics) · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Trace the end-to-end registration flow from client form to server response
- Validate and normalize input — trim, lowercase, and canonicalize email before storage
- Enforce a sensible password strength policy on the server, not just the browser
- Handle duplicate accounts without user enumeration using generic responses
- Build an Express + Mongoose
POST /registerendpoint that hashes with bcrypt (cost 12) - Issue email verification and return a safe response that never includes the password hash
Estimated Time: 70 minutes
Practice: Implement a registration handler that normalizes email, rejects weak passwords, and resists enumeration.
In This Lesson
The Registration Flow, End to End
"Sign up" looks like one button, but behind it sits a short assembly line. Each station has one job, and skipping any of them opens a hole. Before writing a line of code, hold the whole pipeline in your head: the browser collects and pre-checks input, the server re-checks and cleans it, decides whether the account can exist, protects the password, persists the user, and starts verification — then answers.
Here is the sequence you'll build. Notice that the browser's checks are only a convenience; the server repeats every one of them, because anything sent from a client can be forged.
🏢 Analogy: the reception desk
Think of registration as checking into a secure building. The web form is the visitor slip you fill out. Reception (the server) doesn't trust the slip blindly — they re-verify your details, check whether you already have a badge, lock your ID in a safe (the hash) rather than pinning it to a corkboard (plaintext), and mail your access card separately (email verification). And crucially, they never announce to the lobby whether "someone with that name already works here" — that would help an impostor map out the staff.
The Client Form
The form is where the flow starts. Its job is to collect the minimum needed to create an account and to catch obvious mistakes early so users aren't bounced back by the server. But client-side validation is a user-experience feature, never a security boundary — a determined caller can hit your API directly with curl and skip the form entirely.
Here is a compact, framework-free version. It POSTs JSON and shows whatever generic message the server returns.
const form = document.querySelector('#signup-form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
// Read fields. The server will re-validate all of this.
const email = form.email.value;
const password = form.password.value;
// Cheap client-side guard for fast feedback (NOT security):
if (password.length < 12) {
showMessage('Password must be at least 12 characters.');
return;
}
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
// Show the SAME friendly message on success or "already exists".
showMessage(data.message);
});
💡 Why so few fields?
Every extra field is friction that costs you sign-ups. Ask only for what you need to create the account — usually email and password. Display names, avatars, and preferences can be collected later, after the user is already in the door. This is called progressive profiling.
Validate & Normalize Input
The moment a request arrives, the server's first job is to decide: is this input even shaped correctly, and what is its canonical form? Two distinct steps hide here.
- Validation asks "is this acceptable?" — is the email a real email, is the password long enough?
- Normalization asks "what is the one true version of this value?" — trim stray spaces, lowercase the email so
Ada@Site.comandada@site.comcan't become two accounts.
Do both on the server. Here's a hand-rolled version so you can see exactly what's happening, followed by the library approach you'd ship.
By hand — see the mechanics
// A pragmatic email pattern. Perfect email validation is famously
// impossible; the real test of an address is whether a verification
// email arrives, which we do later.
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function normalizeAndValidate({ email, password }) {
const errors = [];
// Normalize FIRST so validation runs on the canonical value.
const cleanEmail = String(email ?? '').trim().toLowerCase();
if (!EMAIL_RE.test(cleanEmail)) {
errors.push('Please provide a valid email address.');
}
if (typeof password !== 'string' || password.length < 12) {
errors.push('Password must be at least 12 characters.');
}
return { cleanEmail, password, errors };
}
With express-validator — what you'd ship
In production, lean on a maintained library. normalizeEmail() handles subtleties like lowercasing and provider-specific rules; trim() and escape() defend against stray whitespace and injection in reflected output.
const { body, validationResult } = require('express-validator');
const validateRegistration = [
body('email')
.trim()
.isEmail().withMessage('Please provide a valid email address.')
.normalizeEmail(), // canonical, lowercase form
body('password')
.isString()
.isLength({ min: 12 }).withMessage('Password must be at least 12 characters.'),
// Collect results into one place for the handler to use.
(req, res, next) => {
const result = validationResult(req);
if (!result.isEmpty()) {
return res.status(400).json({ errors: result.array().map(e => e.msg) });
}
next();
},
];
module.exports = { validateRegistration };
⚠️ Normalize before the uniqueness check
If you check the database before lowercasing, Ada@Site.com slips past a lookup for ada@site.com and you end up with duplicate accounts for the same person — plus a login that only works with the exact original casing. Always normalize, then query, then store the normalized value.
Password Strength
A strong hash (last lesson) protects passwords after a breach. A strength policy stops weak passwords from getting in at all. Modern guidance (NIST SP 800-63B) has shifted away from forced complexity rules like "one uppercase, one symbol" — which just produce Password1! — toward length and blocklists of known-breached passwords.
// A modern, NIST-aligned strength check: favor length, block the obvious.
const COMMON = new Set([
'password', '123456789012', 'qwertyuiop12', 'letmein12345',
]); // In production, screen against a large breached-password list.
function checkPasswordStrength(password) {
const errors = [];
if (password.length < 12) {
errors.push('Use at least 12 characters — length beats complexity.');
}
if (password.length > 72) {
// bcrypt only reads the first 72 bytes; cap input to avoid silent
// truncation surprises.
errors.push('Password must be 72 characters or fewer.');
}
if (COMMON.has(password.toLowerCase())) {
errors.push('That password is too common — pick something unique.');
}
return errors; // empty array === strong enough
}
✅ Length over complexity
A 16-character passphrase like correct-horse-battery is far stronger and far more memorable than P@ss1!. Set a generous minimum length, cap it below bcrypt's 72-byte limit, screen against a breached-password list (the Have I Been Pwned range API is perfect for this), and drop the arbitrary symbol rules.
Duplicate Users & Enumeration
Every registration must answer one question: does an account already exist for this email? The naive approach replies "Email already in use" when it does. That single helpful message is a security leak called user enumeration: an attacker can now probe your endpoint with a list of emails and learn exactly which ones have accounts — a shopping list for password-spraying, phishing, and credential-stuffing attacks.
The anti-enumeration pattern
Return the same response whether or not the account already existed. If the email is new, create the user and send a verification email. If it already exists, quietly send a "someone tried to register with your email — log in or reset instead" email and return the same generic success. Either way the client sees "check your email," and the attacker learns nothing.
// Same outward response in both branches — no enumeration signal.
async function handleExistingOrNew(cleanEmail, password) {
const existing = await User.findOne({ email: cleanEmail });
if (existing) {
// Don't reveal existence. Optionally email the owner a heads-up.
await notifyAccountAlreadyExists(cleanEmail);
return; // caller sends the generic success below
}
const passwordHash = await bcrypt.hash(password, 12);
await User.create({ email: cleanEmail, password: passwordHash });
await sendVerificationEmail(cleanEmail);
}
⚠️ The database unique index is your backstop
Two requests for the same new email can race past your findOne check at the same moment. Always add a unique index on email so the database rejects the second insert (a duplicate-key error, Mongo code 11000). Catch that error and return the same generic response — never a 500.
The Express Endpoint
Now assemble the pieces. Below is a complete, modern registration flow with Mongoose. It hashes in a pre('save') hook (so the plaintext never lives on the document), hides the hash with select: false, and enforces uniqueness at the schema level.
The user model
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const SALT_ROUNDS = 12;
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true, // enforced by a DB index — the real backstop
lowercase: true, // normalize on the way in
trim: true,
},
password: {
type: String,
required: true,
select: false, // never returned by default queries
},
isVerified: { type: Boolean, default: false },
verifyTokenHash: { type: String, select: false },
verifyTokenExpires: { type: Date, select: false },
}, { timestamps: true });
// Hash whenever the password is set or changed.
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, SALT_ROUNDS);
next();
});
// Create a verification token: return the RAW token (emailed to the
// user) but store only its HASH, so a DB leak can't be used to verify.
userSchema.methods.createVerifyToken = function () {
const raw = crypto.randomBytes(32).toString('hex');
this.verifyTokenHash = crypto.createHash('sha256').update(raw).digest('hex');
this.verifyTokenExpires = Date.now() + 24 * 60 * 60 * 1000; // 24h
return raw;
};
module.exports = mongoose.model('User', userSchema);
The route handler
The controller ties validation, the enumeration-safe check, hashing (via the hook), and email verification together — and shapes a response that never carries the hash.
const User = require('../models/User');
const sendVerificationEmail = require('../utils/sendVerificationEmail');
// POST /api/auth/register (validateRegistration middleware ran first)
async function register(req, res) {
// Input is already validated + normalized by middleware.
const email = req.body.email; // normalized lowercase
const { password } = req.body;
// Server-side strength gate (never trust the browser).
const weak = checkPasswordStrength(password);
if (weak.length) return res.status(400).json({ errors: weak });
// Generic message used in BOTH branches to prevent enumeration.
const GENERIC = { message: 'Check your email to finish signing up.' };
try {
const existing = await User.findOne({ email });
if (existing) {
await notifyAccountAlreadyExists(email); // optional heads-up email
return res.status(200).json(GENERIC); // same message, no leak
}
// Assigning plaintext is fine — the pre('save') hook hashes it.
const user = new User({ email, password });
const rawToken = user.createVerifyToken();
await user.save();
await sendVerificationEmail(email, rawToken);
// 201 Created. Note what is ABSENT: no password, no hash, no token.
return res.status(201).json(GENERIC);
} catch (err) {
// Unique-index race: two signups for the same new email at once.
if (err.code === 11000) return res.status(200).json(GENERIC);
return res.status(500).json({ message: 'Something went wrong.' });
}
}
module.exports = { register };
Response body (both new and existing email)
{ "message": "Check your email to finish signing up." }
🐘 On PostgreSQL instead?
The shape is identical. Add a UNIQUE constraint on email, hash with await bcrypt.hash(password, 12) before the INSERT, use a parameterized query (INSERT INTO users(email, password_hash) VALUES ($1, $2)), and catch unique-violation error code 23505 the way we catch Mongo's 11000. Select only non-sensitive columns back — never SELECT * into a response.
Email Verification
Creating the account isn't the finish line. Email verification proves the user actually controls the address, which cuts spam sign-ups, blocks typo'd addresses, and gives you a trustworthy channel for password resets. The pattern: email a raw random token, store only its hash, and confirm by re-hashing the token from the link.
const crypto = require('crypto');
const User = require('../models/User');
// GET /api/auth/verify/:token
async function verifyEmail(req, res) {
// Hash the incoming token the same way we stored it.
const tokenHash = crypto
.createHash('sha256')
.update(req.params.token)
.digest('hex');
const user = await User.findOne({
verifyTokenHash: tokenHash,
verifyTokenExpires: { $gt: Date.now() }, // not expired
}).select('+verifyTokenHash +verifyTokenExpires');
if (!user) {
return res.status(400).json({ message: 'Link is invalid or expired.' });
}
user.isVerified = true;
user.verifyTokenHash = undefined; // one-time use — clear it
user.verifyTokenExpires = undefined;
await user.save();
return res.status(200).json({ message: 'Email verified — you can log in.' });
}
module.exports = { verifyEmail };
✅ Why store the hash, not the raw token?
The token is essentially a temporary password for the verify endpoint. If your database leaks and you stored raw tokens, an attacker could verify accounts they don't own. Storing only the SHA-256 hash means a leak yields nothing usable — exactly the reasoning behind hashing passwords, applied to tokens.
💡 Verify-first or limited access?
Two common policies: block login entirely until verified, or grant limited access immediately and unlock full features after verification. The second is friendlier for onboarding; the first is stricter. Either way, keep isVerified on the user and gate sensitive actions on it.
Practice & Quiz
🏋️ Exercise 1: An enumeration-safe registration handler
Goal: Complete register so it normalizes the email, rejects passwords shorter than 12 characters, and returns the same generic message whether or not the account already exists. Assume User (Mongoose) and bcrypt are available and that the model hashes on save.
async function register(req, res) {
const email = /* TODO: normalize req.body.email */;
const { password } = req.body;
// TODO: reject weak passwords (min length 12) with a 400
// TODO: if a user with this email exists, return the generic success
// TODO: otherwise create the user and return the generic success
}
💡 Hint
Normalize with String(req.body.email ?? '').trim().toLowerCase(). Define one GENERIC message object and return it in both the "exists" and "created" branches so the responses are indistinguishable. Let the model's pre('save') hook do the hashing — just assign the plaintext.
✅ Solution
async function register(req, res) {
const email = String(req.body.email ?? '').trim().toLowerCase();
const { password } = req.body;
if (typeof password !== 'string' || password.length < 12) {
return res.status(400).json({ message: 'Password must be at least 12 characters.' });
}
const GENERIC = { message: 'Check your email to finish signing up.' };
try {
const existing = await User.findOne({ email });
if (existing) return res.status(200).json(GENERIC);
await User.create({ email, password }); // hook hashes the password
return res.status(201).json(GENERIC);
} catch (err) {
if (err.code === 11000) return res.status(200).json(GENERIC); // race
return res.status(500).json({ message: 'Something went wrong.' });
}
}
🏋️ Exercise 2: Spot the leak
Goal: This handler works, but it leaks information and exposes sensitive data. Name at least two problems and how you'd fix them.
async function register(req, res) {
const { email, password } = req.body;
const existing = await User.findOne({ email });
if (existing) return res.status(409).json({ message: 'Email already in use' });
const hash = await bcrypt.hash(password, 12);
const user = await User.create({ email, password: hash });
return res.status(201).json({ user }); // returns the whole document
}
✅ Solution
- User enumeration: the distinct
409 "Email already in use"tells an attacker the email is registered. Fix: return the same generic success in both branches. - Returns the password hash:
res.json({ user })serializes the full document includingpassword. Fix: useselect: falseon the field and return only a message (or a whitelisted subset like{ id, email }). - No normalization:
emailisn't trimmed or lowercased, soAda@Site.comcan create a duplicate. Fix: normalize before the lookup and store the normalized value with a unique index.
🎯 Quick Quiz
Question 1: Why must the server re-run the same validation the browser already did?
Question 2: What is "user enumeration" and how do you prevent it during registration?
Question 3: Why store a hash of the email-verification token instead of the raw token?
Best Practices & Pitfalls
✅ Do
- Validate and normalize on the server — trim, lowercase email, canonicalize before storing
- Enforce password strength by length and blocklist, not arbitrary symbol rules
- Return a generic, identical response whether or not the account already exists
- Add a unique index on email and catch the duplicate-key error as a backstop
- Hash with bcrypt (cost ~12) at the point the password is set, via a
pre('save')hook - Keep the hash out of responses (
select: false) and store only the hash of verification tokens - Rate-limit the endpoint and consider a CAPTCHA to blunt automated abuse
❌ Don't
- Trust client-side validation as a security control
- Reply
"Email already in use"— it hands attackers a list of valid accounts - Query the database before normalizing the email (creates duplicates)
- Return the whole user document — the password hash rides along with
res.json({ user }) - Store raw verification tokens, or let them live forever (always set an expiry)
- Log request bodies containing plaintext passwords
⚠️ The classic one-liner leak
// ❌ Serializes the ENTIRE document, password hash included:
res.status(201).json({ user });
// ✅ Return only what the client needs:
res.status(201).json({ message: 'Check your email to finish signing up.' });
Even with select: false, a freshly created in-memory document may still carry the field. Return a message or an explicit whitelist — never the raw model instance.
Summary
🎉 Key Takeaways
- Registration is a pipeline: validate → normalize → check existing → hash → create → verify → respond
- The browser's checks are UX; the server is the only real security boundary — re-validate everything
- Normalize before you query and store so casing and whitespace can't create duplicate accounts
- Favor password length and blocklists over arbitrary complexity rules
- Return a generic response either way to avoid user enumeration; back it with a unique index
- Hash with bcrypt (cost 12), store only a hashed verification token, and never return the password hash
📚 Additional Resources
- OWASP — Authentication Cheat Sheet
- OWASP — Preventing account enumeration
- NIST SP 800-63B — Digital Identity Guidelines (password guidance)
- bcrypt — npm package
- MDN — HTTP 201 Created
🚀 What's Next?
Your users can now sign up safely. But how do they stay signed in across requests without re-entering their password every time? Next you'll meet JSON Web Tokens (JWT) — how a signed, self-contained token proves identity on each request, what lives inside its header, payload, and signature, and where JWTs shine versus server-side sessions.
📝 Front door secured!
You've built a registration flow that validates, normalizes, resists enumeration, and never leaks a hash. That's the foundation every logged-in feature stands on.