3 December 2023

Bernard devlog #2: the unreasonable UX of email verification

This is a series recording my experience working on Bernard, an automated service that monitors your website for broken links and other issues.

I have been preparing to release the open beta of Bernard, and remembered at the last minute that I will need email verification to avoid spammers and other malicious actors accessing the service.

Without email verification, a user could create an account, add up to 5 websites to monitor for broken links, increasing the load on the target webserver. With an automated script to create accounts, they have a free, ghetto website DDoS at their disposal. We can't allow that, so, like every serious website out there, let's add email verification during sign-up.

Sounds easy enough.

Let's go. In and out. Twenty minute adventure.

Email verification is such a mundane task, in the grand scheme of things. But, what was a throwaway task on my to-do list to complete over the weekend, it's actually a hidden pit of complexity and major UX friction.

Bernard is written in Elixir, using the Phoenix Framework. Since authentication is such a complex topic that every app eventually needs to deeply customize, Phoenix does not provide an authentication library, but instead ships with a code generator that gets you 80% of the way there, which you can later modify to your liking. This generator also creates the foundation of an email verification system, but does not enforce it upon login, that's up to you. Following the existing code and filling in the blanks, this is how it would work:

  1. User enters their email, password and password confirmation. Clicks submit.
  2. A toast message tells them to click the link they have received in their email.
  3. User looks for the email, clicks the link.
  4. A page with a button to confirm the validity of the email is shown. User clicks the button.
  5. A toast message thanks them for verifying, redirects to the login form.
  6. User logs in.
  7. Done.

This is what stared back at me when I broke down the weekend task of adding email verification. This is ludicrous. These are 6 places where things can go wrong, where a user might get distracted and forget about the app forevermore. And most of this complexity exists for security reasons.

Can we make it simpler?

Why is step 4 necessary? Why do I have to click on the verification link, and then press a button to bloody confirm again? The reason is that email providers process and scan emails to cache images, check against phishing, etc. For this reason, the verification should not happen over a GET request, but needs to make sure it's initiated by a user. The page in step 4 is a dumb form that sends a POST request to verify the email.

Why is step 5 necessary? Why do I have to log in again after confirming my email? Quoting a comment from the default Elixir auth generator: "Do not log in the user after confirmation to avoid a leaked token giving the user access to the account." Once again, security.

Many people don't have an email client configured on their PC, or simply find it much easier to get notified and read emails on their phone. Millions of users every day sign up to websites on their PC, get told to click the verification link in the email, and simultaneously receive a ping sound on their phone. You've got mail. Now they might complete the rest of the steps on a cramped phone screen, until they are asked to log in, in which case they have to decide whether it's worth to proceed on their phone (I hope your website is mobile ready!), or return to their PC.

As an exercise left to the reader, imagine how would you deal with a user that loses their verification email. Your app needs a way to resend a verification link. You also probably need to rate limit how many times one can ask to resend the verification email.

This is terrible user experience at the worst possible time: just after the user might have expressed a little interest in trying out the app, and just before they have seen what the app can do for them. Every website requires something like this nowadays, so we're numb to the process and can do it with our eyes closed, but let's see how easy is it to improve the status quo. Let's explore a few alternative approaches that might work for Bernard.

Approach 1: verify users after login

One way would be to let the users in immediately after registration (step 1), giving them the chance to explore the app, while reminding them, with a small message that's always visible, that they need to verify their email.

I'm not sure this approach works for Bernard, as it's based on the idea of monitoring a website and forgetting about it: if something happens, you'll be notified by email. But we won't send out emails if your email isn't verified, so there's a non-zero chance of a user that creates an account, adds their website to be monitored, and forgets about verification. Bernard might find issues worth notifying about, and no way to do that. I have no mouth and I must scream.

Alternatively, require the user to verify their email just before they add their first website, but we'd be back to square one: asking the user to go through a ton of bureaucracy just before the app has shown what it can do for them.

Approach 2: start with email verification

Here's an idea I don't think I've seen much in the wild: instead of starting the registration process by asking email and password, just ask for the email. User receives a link in the email, which doubles as verification, and completes the rest of the sign-up process: enter password, confirm password, click here if you want to subscribe to the newsletter, done. Redirect to the app.

I quite like this approach, but has a major security flaw: an attacker might start spamming unsuspecting email addresses with your sign-up link, so you need rate limit and constant monitoring. I want to ship an open beta ASAP, not spend days on this problem.

Sending a code via email, instead of a verification link would simplify the process a little: no need to deal with automated email scanners that open links for you, so step 4 (follow verification link, press button) is unnecessary. Also works great with people that open their emails on their phone. They just have to read the code and enter it on their computer.

This might be the option I will choose for Bernard, and we can reduce this complexity to its minimum:

  1. Register with email and password. Receive a code via email.
  2. User logs in, and lands on a page that asks to enter, or resend the code.
  3. User reads the code on email client or phone. Enters it. They are redirected to the app.
  4. Done.

I'm glad I wrote this post, because I was torn between doing the easiest thing (just ship it, how hard can it be!), which would have added a ton of friction, when a simpler way is possible, but requires some additional work. This is proof that Phoenix shipping with a code generator and not an authentication library is a great idea. You will have to customize it sooner or later.

Email verification is usually a footnote, a 20-minute job to deal with just before launching the MVP, but in reality, it's one of the most crucial steps to get right, as arguably there are fewer things more important than user onboarding. All of its complexity exists for security reasons and because the internet is not a safe place, and honestly, as a developer and a user, it's something I'd rather not have to think about. Will we ever get rid of it?

View all posts