Signup on a site I run asked people to prove they owned their email address, and then asked them again.
First a six-digit code, emailed and typed back in. Only after that does the account get created. Then, on creation, the auth service sent its own confirmation email with a link in it.
Two emails. One address. Same question asked twice.
It read as thorough. It was actually a fork in the data, and it had already put a real user somewhere the system has no sensible answer for.
The state nobody designed
Someone signed up. They received the code, entered it correctly, and the account was created. The app signed them in immediately, as it is written to do.
They never clicked the second link. Why would they? They had just typed a code out of their inbox and were now looking at a logged-in page. From where they sat, the job was done.
So the record says: account exists, password set, session valid, signed in — and email_confirmed_at is null.
That row is not describing a half-finished signup. It is describing a completed one, plus a leftover flag from a check that nobody needed and nothing enforced. But a flag called email confirmed saying no is going to be read by something, eventually. Any support tooling, any audit, any future feature that gates on confirmation, will look at that user and conclude they never proved their address.
They did. Six weeks earlier. There is a used code in another table with their name on it.
Two records, one fact
This is the reframe that took me embarrassingly long.
I had been thinking of the second check as redundancy — belt and braces, no harm in it. It is not redundancy. It is a second place where the same fact is recorded, and two records of one fact will eventually disagree.
The fact is "this person controls this address." My OTP table knows it, in the form of a code marked used. The auth service knows it, in the form of a timestamp. Nothing keeps the two in sync, because they were never designed as one thing — one is application logic, the other is a platform default that was left on.
The moment a user satisfies one and not the other, the fact has two values. And when a fact has two values, every consumer downstream has to pick which one to believe, usually without knowing there is a choice being made.
The one that wasn't enforcing anything
There is a version of this where the second check is the real one and the first is a convenience. That would at least be coherent.
It was not that. The link enforced nothing.
The user was signed in before the confirmation email could possibly have been opened, because the app calls sign-in directly after creating the account. Sign-in did not consult the flag. Nothing in the product consulted the flag. The account worked identically whether the link was clicked or not.
So the second check could not block anything, could not gate anything, and could not fail in any way the user would notice. Its entire observable effect was to set a column that then contradicted the other record of the same fact.
That is the test worth applying: if a check cannot refuse anything, it is not a check. It is a log entry with an opinion.
Why it was on at all
Nobody chose this. The confirmation email is the platform default, and it is a sensible default — for an app whose signup is "create account, we'll email you a link." That is the flow it was designed for.
We had replaced that flow with our own and never turned the old one off. The original code even said so, in a comment:
// Create the user account with auto-confirm (since we verified email via OTP)
The intent was recorded. The setting it referred to was never changed. So the comment described a system that did not exist, sitting directly above the line that created the problem, for months.
I have a lot of sympathy for that comment. It is what happens when you build the replacement and reasonably assume the thing it replaces has stepped aside.
Fixing it, and the row already broken
Turning off the platform confirmation was one setting. New signups now get exactly one email, containing exactly one code, and land signed in with a single coherent record of the fact.
The stranded user needed a separate decision, and it is a more interesting one than it looks. The options were:
- Leave them flagged unverified, which is false.
- Set the flag to now, which is also false — it says they proved ownership today, which they did not.
- Set it to the moment they actually entered the code.
I took the third. Their confirmation timestamp is now thirty-three seconds before their account was created, which looks like a data error and is in fact the most accurate statement available: they proved they owned that address, then the account was made.
I would rather have a timestamp that looks odd and is true than one that looks tidy and is not. Anyone who investigates that row will find the corresponding used code sitting right there in the other table, and the sequence will make sense.
What I would ask earlier next time
When you replace a platform flow with your own, the work is not finished when yours works. It is finished when the platform's version is off.
Until then you do not have a verified email address. You have two claims about a verified email address, maintained by different systems, agreeing by coincidence — and coincidences hold right up until a user does something entirely reasonable, like reading their inbox once instead of twice.
Top comments (0)