The Signup States Nobody Tests
Stage: the states between form and account. In control: your code.
1. Registration is a state machine wearing a button
Signing up is not one action. It is a sequence of states an account occupies, and the majority of registration defects live in the states nobody opened by hand.
The path that gets exercised constantly is the straight one: form submitted, message sent, link clicked, account active. Everything branching off that path gets built once, reasoned about briefly, and then never seen again by the person who wrote it.
Listing the states explicitly is most of the work. A row exists but is unconfirmed. Its confirmation window has passed. The same address arrives at the form a second time. A confirmation lands after the address on the record has changed. An account is fully active on an address that has stopped existing. Each of those is a real position your data can be in on any given morning.
That last one is more common than it looks. Addresses of the kind this site hands out are built to disappear on a schedule, so a share of your accounts are permanently active and permanently unreachable, and your code has to have an opinion about them.
2. The second signup on the same address is the common hole
A person filling the form again with an address you already hold is the single most frequent branch, and the usual response leaks your user list.
The message "this address is already registered" answers a question that anyone can ask about anyone. Feed a form a list of addresses, read which ones come back as taken, and you have a membership roster for a service where membership itself may be sensitive.
The usual replacement is a response that does not vary: the form always reports that a message has been sent, and the message differs instead. A new address receives a confirmation link, an unconfirmed one receives its link again, and a confirmed one receives a short note saying somebody tried to register with it, with a link to sign in or reset the password.
This is where it goes wrong in practice. A person who genuinely forgot they had an account is now staring at a screen that says check your email while their mailbox holds something other than what they expected. The note has to say plainly what happened and what to do, or you have swapped an information leak for a support ticket.
3. The expired token deserves better than a dead end
Confirmation links expire, and the page they lead to after expiry is usually the least considered screen in the product.
A page reading "this link is no longer valid" with nothing else on it is a defect rather than a security measure. The person did nothing wrong. They opened a message a day late, which is ordinary behaviour, and now they are holding a broken account with no route forward.
The screen should offer to send a new link, and where the pending record can be identified safely it should do so without asking for the address again. Nothing about issuing a fresh token is weaker than issuing the original, because the old one is gone either way. Decide the length of the window at the same time, and make the code and the message agree: a message promising twenty four hours over a token that lives for one is a defect the user experiences as your service being broken.
4. Two tabs produce two confirmations at once
A person opens the confirmation link, nothing appears to happen, and they click it again. Or the message opens in a preview pane and a browser at the same time. Two requests carrying the same token arrive within a second of each other.
If activation is a read followed by a write with no protection between them, both requests see an unconfirmed record and both proceed. Depending on what activation triggers, that means two welcome messages, two provisioning calls, two rows in a table with a unique constraint that now throws, or a duplicate charge on a flow that takes payment at signup.
The fix is that the token consumption is the atomic step. A single conditional update that marks the token used and returns whether it changed anything, with everything else hanging off that result. The second request finds nothing to change and shows the same success screen, because from the person's point of view the second click succeeded too.
5. Changing the address mid-flow breaks in one predictable way
An account with a confirmed address that requests a new one is the branch that quietly loses people.
The mistake is switching immediately. The record now holds an address that has never been proven, and if the person mistyped it, or the mailbox does not exist, or the message never arrives, they are locked out of an account that was working ten seconds ago. The old address is gone from the record, so there is nothing to send a recovery message to.
The working shape is a pending column. The new address sits there unconfirmed while the old one remains the live address for sign-in and for every message the system sends. Only a successful confirmation of the new address promotes it and drops the old one. If the confirmation never comes, nothing was lost and the account still works.
The same discipline covers the case where a confirmation for an address change arrives long after a second change request. The token has to name the address it was issued for, not just the account, or a stale link can silently reinstate an address the person already abandoned.
6. The unconfirmed rows need a policy, not a cron job nobody wrote
Records that were never confirmed accumulate from the first day, and the reasons to clear them are not about disk space.
They hold an address, which means personal data with no consent behind it and no relationship to justify it. They occupy the unique index, so a real person registering later can collide with an abandoned attempt made by somebody typing their address by mistake. They distort every count you have of how many users exist.
Pick a window, delete what has passed it, and log the deletion count so you notice when it changes shape. A sudden pile of unconfirmed rows on one address usually means somebody is probing your form rather than that your product got popular.
7. What ties every one of these to a single choice
Look at the list and the pattern is visible: the address is doing a job it is not stable enough to do. It identifies the record before anything is proven, it changes underneath the identity, and it is the key that a duplicate collides on.
The states get much simpler when the account has an identifier of its own and the address is an attribute attached to it, which is not a stylistic preference but the thing that makes half the branches above stop existing.
The handover
Control here has never left your code, which is the good news and the reason none of this can be blamed on the user. Everything above assumes the address arrived at your server intact, and how it got there is described in what happens the moment you submit, while what the confirmation click genuinely establishes is in the confirmation link and what it proves. The column the states are written against should have been shaped as in storing an address without breaking it, and the root cause named in the last section is unpacked in the address as a user id. If you were hoping to drive these branches from a script against this service, why there is no API here explains why that door is closed and what stands in its place.
Read next
The Address as a User ID
An address changes, leaks into URLs and has no stable form. Keep an internal id and make the address an attribute. What breaks otherwise.
Signing In With an Account You Have
The site receives an id, an address, a name and a picture, never your password. The provider receives a list of everywhere you sign in.
When the Address Becomes the Password
With link sign-in your inbox becomes the password. How the token works, why links arrive already used, and what an expiring inbox cannot hold.