Before adding the 2FA layer, this episode builds the authentication foundation: password registration and login with bcrypt, HttpOnly session cookies and JWT, the user model structure with a TOTP secret column, and the auth endpoint map that will be injected with the MFA challenge in episode 8.

The 2FA layer doesn't stand alone — it attaches to existing authentication. Episode 3 makes sure that foundation is solid: password registration and login, hashing with bcrypt, HttpOnly cookie-based sessions, the JWT alternative, and the user model structure that will store the TOTP secret.
Why is this episode important? Because almost all 2FA bugs come from a broken foundation: sessions that aren't rotated, plaintext passwords, or a 2FA flag that can be bypassed. With a correct foundation, episode 8 only needs to insert one verification step in the middle of the login flow.
Passwords must never be stored in plaintext. Use bcrypt or argon2, which adds a salt automatically and is intentionally slow to slow down offline attacks. Install the hashing library and start the project:
npm init -y
npm install express@5.2.1 bcryptjs express-session cookie-parserbcryptjs is a pure JavaScript bcrypt implementation that needs no native compilation. On registration, store the hash, not the plaintext; on login, compare the input with bcrypt.compare.
When a user signs up, the server validates the input, checks the email is unique, then stores the email and the password hash in the users table. Never return the hash to the client. The login flow simply reverses the process: find the user by email, verify the password, then create a session.
For browser-based applications, the most secure session storage is in an HttpOnly and Secure cookie — JavaScript can't read the cookie, so it resists XSS. After a successful login, the server signs the session and sends the cookie via the Set-Cookie header.
Example express-session configuration with a secure cookie:
app.use(session({
name: 'sid',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 30 * 60 * 1000
}
}));Notice httpOnly: true and sameSite: 'lax' — both are the first line of defense against XSS and CSRF that we will strengthen in episode 13.
If you're building an API without a browser (a mobile app or a pure SPA), JWT is a popular alternative. The server signs a token with claims like sub and exp, the client stores it and sends it in the Authorization header. The weakness: a token can't be revoked server-side before it expires, so server-side sessions are generally preferred for a 2FA feature. Episode 15 will discuss the relationship between JWT and the 2FA flag in detail.
The 2FA foundation requires several new columns on the user model. The TOTP secret is stored encrypted (episode 7), not in plaintext:
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
totp_secret_encrypted TEXT,
totp_enabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);The three key columns are totp_secret_encrypted, totp_enabled, and later a recovery codes table. The totp_secret_encrypted column stays empty until enrollment completes in episode 6.
Besides the columns above, add a recovery_codes table that stores the hashes of single-use recovery codes. Episode 9 will dissect their generation and lifecycle, but the table structure can be created now so the migration runs in one pass.
The auth endpoint map in your project now looks like this. The 2FA challenge will slot in between POST /login and the creation of the full session in episode 8:
POST /register -> create user + hash password
POST /login -> verify password, set pendingMfa if 2FA is active
POST /login/mfa -> verify TOTP, grant full session
POST /logout -> destroy session
GET /account/settings -> 2FA management pageNote that POST /login doesn't immediately grant a full session when 2FA is active — it only marks pendingMfa. This is the bridge to episode 8.
Episode 3 laid the authentication foundation: passwords hashed with bcrypt, sessions stored in HttpOnly cookies, JWT as the API alternative, a user model with TOTP secret columns, and the endpoint map waiting for the MFA challenge.
The key takeaways:
In the next episode, episode 4, we will cover generating the secret and the otpauth URI — creating a random secret per user with authenticator.generateSecret and assembling the provisioning URI that Google Authenticator recognizes.