← All posts

User Registration Is Never Just a Form

"Add user registration" sounds like a checkbox, but what does that actually entail? What does a registration flow really need to hold up: not just accept a signup, but keep bad actors out, keep passwords safe, and make sure the person on the other end is who they say they are?

Even the form isn't free

Before any of what follows, there's the form itself: two fields, submit button, done, right? Except now it needs styling that matches the rest of your app, inline validation so a bad email address or a too-short password doesn't round-trip to the server before the user finds out, and error states that don't look like an afterthought. None of that is hard. It's just real time, before you've stored a single user.

The form works. Now you need somewhere to put that user

That row can't just land in a table as-is. The password can't sit there as plain text, and it can't be encrypted either (encryption is reversible if you have the key, which means a breach hands over every password on your system). It has to be hashed: a one-way fingerprint you compare against, never reverse. And you shouldn't write that hashing yourself. It needs to be deliberately slow and resistant to brute-forcing, which is exactly the kind of non-obvious property a purpose-built library gets right and a homegrown one doesn't. On the Rails side, Devise handles this for you via bcrypt. On the Go side, I'm using golang.org/x/crypto/bcrypt, same algorithm, same reasoning.

The user's stored, but you don't know it's really them

Anyone can type an email address into a form. So you need email verification, both to confirm the address is real and to keep bots from mass-registering accounts. Which means you need to be able to send an email. Which means you need an SMTP setup before you've built anything a user will ever see.

Locally, I'm using Mailpit, a real SMTP server in Docker with a local inbox UI. If you're coming from Rails, your instinct is probably Letter Opener instead, and it's genuinely easier to drop into a Rails-only app. I went with Mailpit because it's language-agnostic: the exact same setup works for the Go template too, one tool instead of two. In production, that email goes out through Resend, mostly because the free tier is generous enough that I don't have to think about it yet.

Sending it can't block the request

You can't fire that email synchronously inside the registration request, or the user's signup hangs on a third-party API call. Invisible in dev, painful the first time Resend has a slow moment in production. So sending has to happen off to the side.

On the Rails side, that's Solid Queue, which hooks straight into Postgres, no Redis required. On the Go side, there's no equivalent convention to reach for, so I kept it simple: a goroutine. No persistence, no retry, if the process dies mid-send the email's just gone. Not the "proper" answer, but for a fire-and-forget verification email at this stage, that's a fine tradeoff.

The link works, until Gmail gets to it first

The user has to click the link to verify. Simple, right? Build the endpoint as a plain GET: click the link, hit the server, mark the account verified. Works fine in every manual test you'll ever run.

Then a real user with a Gmail account signs up, and their account gets marked verified before they've clicked anything. Gmail's link-scanning pre-fetches links in incoming email to check for phishing and malware, which means it hits your GET endpoint itself, and if that endpoint mutates state, Gmail just verified the account on the user's behalf.

So the endpoint has to be a POST instead. Gmail's scanner won't submit forms, only fetch links, which closes the hole, but it also means you can't just have a bare "click here" link anymore. Now you need a landing page with a confirm button that fires the POST. A small UI you didn't budget for until you hit this.

That's registration for a single method. One more thing worth naming: today, users also expect to sign up with Google or GitHub, so there's another users table decision buried in here too, making sure it can hold whatever an OAuth provider hands back, and still make sense for someone who signs up with Google today and adds a password next month.

And that's it. That's registration.

A users table that stores passwords correctly, and holds up for OAuth users too. A local email tool and a production email provider, wired up the same way. A queue or a goroutine so signup doesn't hang on a network call. A landing page and a POST endpoint just so Gmail doesn't verify accounts on your users' behalf.

You still haven't built login, sessions, or password reset.

If you'd rather not build all of that yourself, it's already solved in the templates over at rdooley.dev.