14 min read
Implemented in Registration Kit
Full-stack auth starter: registration, login, 2FA, and password reset — React + Express + MongoDB
See Registration KitOwning authentication is the decision most likely to be made for the wrong reason. So, plainly:
If you need SAML, SCIM provisioning, an enterprise identity-provider directory or a SOC 2 report you can hand to a procurement team — buy it. Building that is a product, not a feature, and the vendors are cheap next to doing it yourself.
If you need email and password, social login, two-factor and password reset for your own users — that is a bounded, well-understood problem, and paying per monthly active user for it stops making sense somewhere in the low tens of thousands.
The mistake is treating those two as the same decision.
Identity vendors price on monthly active users, and MAU grows with your success rather than with your usage of the product. You pay more for authentication in a month where a marketing campaign worked, even though authentication did nothing different.
The pattern across the category is a generous free tier, a comfortable middle, and a step where B2B features and higher MAU push you into a different bracket — often a large jump rather than a slope. Check your own numbers rather than trusting a table on a blog: the shape that matters is how the line bends between your current MAU and 10× that, and whether the features you will need by then sit above a plan boundary.
The other cost is the one nobody prices: the user table is theirs. Every product decision that needs to join users to your own data crosses a network boundary, and leaving later means the migration in the second half of this page.
Strip the marketing and a normal application needs seven things:
httpOnly, Secure, SameSite, or short-lived JWTs with rotating refresh tokens.That is a well-mapped problem. Every item has a correct answer that has not changed in a decade, and none of it requires invention.
This is where self-hosting authentication actually goes wrong, and it is never the parts people expect.
Timing attacks. If a missing user returns instantly and a wrong password takes 200ms of Argon2, your login endpoint answers "is this email registered?" to anyone who asks. You hash a dummy password on the miss to burn the same time. Easy to fix, easy to never think of.
Reset tokens stored raw. A leaked database that contains usable reset links is a leaked database that contains every account. Store the SHA-256 of the token, compare hashes, expire in 30 minutes, single use.
The revocation you forgot. Someone resets their password because an attacker is in the account. If you do not kill every other session on reset, the reset accomplished nothing.
Two-factor lockout. Enable TOTP the moment you generate the secret and the user who closes the tab before scanning the QR is locked out permanently, with only you able to help. Two-step enrolment — generate, then confirm with a code — costs one extra endpoint and removes an entire class of support ticket.
Account enumeration through the side doors. You fixed login, then the signup form says "email already registered" and the reset form says "no account found". Same rule everywhere or the rule does nothing.
Lockout as a denial of service. Permanent lockout after failed attempts means anyone who knows an email address can lock that account. Temporary, per-account, cleared on success.
None of these is hard. All of them are invisible until they are not, which is exactly why the vendors have a business.
The thing people assume is impossible, and usually is not.
Password hashes are portable when the algorithm is. Bcrypt and Argon2 hashes carry their algorithm, cost and salt inside the string, so any library on any stack can verify them. If your provider exports hashes — Auth0 offers a bulk export, Supabase stores bcrypt in auth.users — users move without noticing.
Verify against the old hash, and upgrade transparently on first successful login:
const ok = await verifyLegacy(user.legacyHash, password);
if (ok) {
await users.setPassword(user.id, await argon2.hash(password)); // silent upgrade
await users.clearLegacyHash(user.id);
}If hashes are not exportable, the fallback is lazy migration: keep both systems running, authenticate against the old one, capture the plaintext at that moment, store your own hash, and stop calling out. Slower, invisible to users, and it drains as they log in.
Social logins do not move. They are tied to the provider's registered OAuth apps. You register your own and re-link by verified email on next login. Users who only ever used Google have no password — they need a first-login link, not a reset email that will confuse them.
To be complete about it:
Owning authentication is right when it is a cost centre you can bound and a dependency you want gone. It is wrong when it is a way to avoid a bill you could pay.
If you own it, Registration Kit ships the seven things above already built — OTP signup and email verification, login with identical timing on failure, TOTP two-factor with hashed backup codes, forgot and reset with full session revocation, and JWT sessions with rotating refresh. Node and Go on the same API contract, full source, self-hosted. The reasoning behind the token model is in JWT authentication in Go and Node.js authentication beyond the login form.