📢 If you're building your own auth instead of bolting on a third-party provider but
↳ Every tutorial stops at "hash the password and you're done"
↳ You're not sure which shortcuts will actually get you breached
Then here are the 10 decisions baked into a real Express + TypeScript auth module; register, login, Google sign-in, email verification, password reset, that separate "it works on my machine" from something you'd trust with real accounts.
Most password reuse and account-takeover incidents don't come from exotic exploits. They come from small, boring omissions: an error message that confirms an email exists, a session token stored in plaintext, a reset link that never expires. Auth isn't hard because the crypto is hard. It's hard because it's the one module where every shortcut becomes someone else's incident report.
Here are the 10 decisions I'd copy 👇
1️⃣ Hash passwords with Argon2id, not bcrypt defaults
Everyone knows "don't store plaintext passwords." Fewer people tune the hashing cost for their actual hardware.
↳ Use argon2.argon2id specifically, it resists both GPU cracking and side-channel attacks, which plain Argon2i or Argon2d don't do alone.
↳ Set memoryCost, timeCost, and parallelism explicitly instead of trusting defaults.
↳ Wrap verification in a try/catch that fails closed, a malformed hash should mean "reject," never "throw and crash the request."
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4
});
The memory cost is the part people skip. It's what makes cracking expensive on custom hardware, not just slow on a CPU.
2️⃣ Give login and register the exact same failure shape
This is the auth equivalent of tactic #2 in most SEO threads: don't tell the attacker anything they didn't already know.
↳ Registering with an email that already exists? Same generic error as any other validation failure.
↳ Logging in with a wrong email? Same error as a wrong password.
↳ Logging in with a right email but wrong password? Same error again.
if (!user) {
throw new AppError('Invalid email or password', 401);
}
// wrong password later in the function throws the identical message
Three different failure states, one message. If your error responses let someone enumerate which emails have accounts, you've built a mailing list for credential-stuffing attempts.
3️⃣ Never store the session token you hand out
The cookie value and the database value should never be the same string.
↳ Generate a random 64-byte token with crypto.randomBytes.
↳ Hash it with SHA-256 before it touches the database.
↳ Store the hash, send the raw token to the browser.
↳ On every request, hash the incoming cookie and compare hashes.
If your session table gets dumped, an attacker holding hashes can't reconstruct working cookies from them. This is the same logic as never storing a password, a session token is just a very short-lived password.
4️⃣ Lock the cookie down on every axis, not just httpOnly
httpOnly alone stops casual XSS token theft. It doesn't stop CSRF, and it doesn't stop the cookie leaking over plain HTTP.
↳ httpOnly: true, JavaScript on the page can't read it.
↳ secure tied to environment, true in production, so it never rides over HTTP.
↳ sameSite: 'lax', blocks the cookie from being sent on cross-site POSTs.
↳ An explicit expires matching your actual session lifetime, not "whenever the browser feels like clearing it."
Each flag closes a different door. Ship all four or you've only closed one.
5️⃣ Verify the Google ID token server-side, never trust the decoded JWT
This is the mistake that looks fine in a demo and falls apart the moment someone forges a payload.
↳ Send the token to Google's tokeninfo endpoint instead of just base64-decoding it.
↳ Check email_verified, an unverified Google email is not proof of ownership.
↳ Check aud against your own client ID, or anyone's token from any Google app authenticates into yours.
↳ Check iss matches accounts.google.com.
↳ Check exp hasn't already passed.
if (expectedClientId && data.aud !== expectedClientId) {
throw new AppError('Invalid Google token audience', 401);
}
Decoding a JWT tells you what it claims. Verifying it tells you what's true. Those are not the same operation, and treating them as interchangeable is how you end up letting anyone log in as anyone.
6️⃣ Don't silently merge accounts across providers
Someone signs up with a password using jane@company.com. Later, someone else authenticates with Google using the same email address. Are they the same person?
You don't know. Don't guess.
↳ If a Google email matches an existing password account, reject the sign-in.
↳ Return a clear message pointing to an explicit linking flow instead.
↳ Only ever create the link when the already-authenticated user requests it from their own settings.
if (existingUser) {
throw new AppError(
'An account with this email already exists. Sign in with your password and link Google from your account settings.',
409
);
}
Auto-linking on email match is exactly the kind of implicit trust that turns "forgot my Google password" into "someone else now owns my account."
7️⃣ Make verification and reset tokens single-use and short-lived
A link that works forever isn't a link, it's a standing vulnerability with a nice subject line.
↳ Generate a random token, hash it the same way session tokens are hashed.
↳ Store it with a 30-minute expiry.
↳ Delete any previous unused tokens for that user before creating a new one, so old links stop working the moment a new one is requested.
↳ Delete the token itself the instant it's redeemed, a reset link should not be replayable.
await queries.deleteUserVerificationTokens(`email:${userId}`);
await queries.createVerification({
...,
expiresAt: new Date(Date.now() + VERIFICATION_MINUTES * 60 * 1000)
});
The identifier prefix (email: vs reset:) matters too, it keeps a leaked verification token from being usable to reset a password, and vice versa.
8️⃣ Revoke every session after a password reset or change
If a password reset means an account was compromised, an attacker's still-active session shouldn't survive the fix.
↳ On successful password reset: delete every session for that user, clear the cookie.
↳ On successful password change: same thing, then immediately issue one fresh session for the user who just proved they know both passwords.
↳ Treat "user changed their credentials" as a full trust reset, not an incremental update.
await queries.deleteAllUserSessions(userId);
clearSessionCookie(res);
This is the step that's easiest to skip because nothing breaks in testing if you forget it. It only matters on the one day someone's account was actually taken over.
9️⃣ Validate at the edge, before any handler logic runs
Every DTO in this module has a matching schema, and the schema does more than check "is this a string."
↳ Email: normalized to lowercase, capped at 255 characters, format-checked without requiring a TLD allowlist that goes stale.
↳ Password: minimum 12 characters, long enough to push people toward passphrases instead of Password1!.
↳ Tokens: fixed hex length matching the exact hash output, so a malformed token 400s before it ever reaches a database query.
const password = Joi.string().min(12).max(128).required();
Validation isn't just UX. A schema that rejects garbage before your handler runs is one less code path an attacker can use to find an edge case.
🔟 Rate-limit by intent, not by one global limiter
Login attempts, password resets, and email verifications get abused in different ways and need different ceilings.
↳ authLimiter on register/login/Google, throttles brute-force credential guessing.
↳ passwordResetLimiter, throttles someone hammering forgot-password to spam a target's inbox.
↳ emailVerificationLimiter, throttles resend abuse separately from reset abuse.
One shared limiter either lets credential stuffing through or blocks legitimate users doing something unrelated. Splitting them means each limit can actually be tuned to the attack it's stopping.
None of these ten decisions are visible in a demo. They only show up the day someone tries to abuse the system, and by then it's too late to add them. Build them in from the first commit, the module doesn't get simpler later, only busier.

Top comments (0)