TL;DR Link to heading
Put a long signed token in an email link and you have built a bug that fires deterministically for some recipients and never for others. MIME quoted-printable wraps lines at column 76, some mail providers re-encode the message and mangle the escape that lands on the wrap seam, and if the first seam falls inside the token a single flipped character makes the signature stop matching. The page returns 404. The fix is to keep the URL credential short, an opaque id resolved server-side. But that swap turns a stateless credential into a stateful one, and there is a whole family of transaction-ordering traps waiting on the other side. Those traps are the more interesting part.
The symptom to recognise Link to heading
The tell is a 404 that is deterministic per recipient rather than flaky. The same user’s link fails on every click, a different user’s link works on every click. That shape is worth pausing on, because it rules out load balancers, transient outages, and most caching stories, and it points at something fixed about that specific request. When a link works for you in testing but “sometimes” fails in the wild, and “sometimes” turns out to mean “always, for a stable subset of people”, suspect the content of the URL rather than the infrastructure serving it.
What tends to be in these URLs Link to heading
A common pattern is to carry a signed token in the link, a couple of hundred characters of it. Say roughly 275 characters. The token is signed, not encrypted: an HMAC over a zlib-compressed, base64url-encoded JSON payload, in the classic payload.signature shape. The server verifies the signature, decompresses the payload, reads the claims, and serves the page. If the signature does not match, it returns 404.
A 404 means the signature check failed, and the obvious hypotheses are all about signing: an empty signing key, an encoding mismatch between ASCII and UTF-8, URL escaping eating a character somewhere in transit. Those are worth ruling out first, but if the failure is deterministic per recipient rather than global, none of them fit. A broken key or a bad encoding breaks every link, not a stable subset.
How to tell it is wrap-seam corruption Link to heading
The diagnostic that settles it is to put two strings side by side: the token you recorded sending, and the token the user clicked. Two things to look for.
First, do they share the same signature? If they do, they started life as the same token. You are not looking at two different tokens or a regenerated one, you are looking at one token that changed in transit.
Second, where do they differ? If the difference is a single altered character, and that character sits at a column that is a multiple of 76, you have your answer.
76 is not a random number. MIME quoted-printable, the content-transfer-encoding used for the email body, hard-wraps lines at 76 columns (RFC 2045). Some mail providers re-encode, or “re-quote”, a message into their own storage when they receive it, and in doing so they can mangle the =-escape that falls exactly on a wrap seam, altering or inserting a character. If the first seam lands inside the token, a single flipped byte misaligns the compressed payload, so the still-intact signature no longer matches the now-different payload. Verification fails. 404.
This is not a security problem. The signature is doing its job: it rejects a token that has been tampered with in transit. It is a deliverability problem wearing a security problem’s clothes, and it is easy to burn a day treating it as the latter.
The tempting send-layer fix is to force a 7bit content-transfer-encoding so the body is never quoted-printable wrapped. Worth checking whether your email API allows it. Many treat Content-Transfer-Encoding as a reserved header you cannot set (SendGrid, for one, manages encoding itself), so that door is often closed, and you are back to changing what goes in the URL.
A worked example Link to heading
The link in the email is a long URL with the token in the fragment, the kind of thing a magic link or a “view your details” button points at:
https://app.example.com/r?next=%2Fhome#token=eyJ1c2VyIjoiYWJjMTIzIiwic2NvcGVzIjpbInJlYWQiXSwiZXhwIjoxNzE4MjcwMH0.c2lnbmF0dXJlX2J5dGVzX2dvX2hlcmU
That is one unbroken string, well past a hundred characters. The email body is quoted-printable, which may not exceed 76 columns per line, so the encoder inserts a soft line break, a trailing = then CRLF, wherever column 76 lands. Here it lands inside the token:
https://app.example.com/r?next=%2Fhome#token=eyJ1c2VyIjoiYWJjMTIz=
Iiwic2NvcGVzIjpbInJlYWQiXSwiZXhwIjoxNzE4MjcwMH0.c2lnbmF0dXJlX2J5...
That trailing = is the one clue. base64url has no padding, so it never emits = of its own. The only = in the token is the quoted-printable soft break, and a correct client strips the = and the CRLF and rejoins the two lines into the original URL.
A provider that re-quotes the message decodes it and re-encodes it into its own storage. Mishandle that seam and a stray byte survives the round trip, so the token in the rejoined URL is one character longer than what you sent:
...#token=eyJ1c2VyIjoiYWJjMTIzXIiwic2NvcGVzIjpbInJlYWQiXSwiZXhwIjoxNzE4MjcwMH0.c2lnbmF0dXJl...
^ stray byte where the seam was
base64 is positional: four characters decode to three bytes. Insert one character and every 6-bit group after it shifts, so from the seam onward the decoded bytes are noise. The compressed payload no longer inflates to what was signed. The difference is before the final ., so the signature bytes are untouched: the sent and clicked URLs carry the same token signature over different payloads, which is the fingerprint you are looking for.
The fix: keep the URL credential short Link to heading
The durable fix is to stop shipping a long, self-contained credential in an email URL at all. Instead of a 275-character signed token, mint a short opaque random id, around 22 characters carrying 128 bits of entropy, and resolve it server-side.
The point is to get the whole URL inside a single quoted-printable line, so no soft break falls inside the credential at all. The lever is total URL length, not the token in isolation: a UUID left in the query, a long redirect parameter, or a click-tracking wrapper can push the line back over 76 and a seam back into the token. A short id buys the headroom, but only if you keep the rest of the URL short too. It is also unguessable, because it is random, and it leaks nothing: anyone can base64-decode a signed token to read its claims, since signed is not encrypted. An opaque id is just a lookup key.
Store only the SHA-256 hash of the id server-side, never the id itself, so a database leak does not hand out working links.
Backward compatibility falls out for free. Legacy signed tokens always contain a . separating payload from signature; opaque ids never do. So the router can tell them apart on sight and send old links down the old verification path while new emails carry the opaque id.
The traps: a stateless credential is not a stateful one Link to heading
The fix changes the shape of the problem.
The old token needed no database row. Every claim it carried was inside it, signed. “Generate it, put it in the email” was safe, because there was no state anywhere that the email could get out of sync with.
An opaque id is the opposite. It only works if its database row exists when the user clicks, which means the row has to be committed before the email is sent. Swapping a stateless credential for a stateful one moves the entire correctness burden onto ordering, and that ordering is subtle in more than one place. These are the traps to watch for.
Mint then roll back. A path that creates the row and then raises an HTTP 409 further down the same handler loses the row. The 409 rolls the request transaction back, so the row never persists, but the email has already gone out pointing at it. That is the same 404 as the original bug, reached through a different mechanism. The fix for a 404 reintroduces the 404, which is the trap a stateful credential sets. Mint the row and send only once the request is committed to succeeding.
A best-effort write that poisons the transaction. A “best effort” audit UPDATE, a usage counter, something you are happy to let fail, is not free in Postgres. A failed statement aborts the whole surrounding transaction, so a later COMMIT errors out and a perfectly valid link 404s. Wrap that write in a SAVEPOINT, a nested transaction, so its failure stays contained and does not take the real work down with it.
Committing in the middle of the handler. The blunt fix for an email-before-commit path is to call commit() partway through the handler so the row is durable before the send. It appears to work. It also breaks test isolation, because a suite that rolls each test back no longer can, and in production it commits partial state if anything after it fails. Reach for it and you have traded one failure mode for a quieter one.
That last trap is where the general rule lives. In a request-scoped transaction you can safely commit before an external send, because the request owns the transaction boundary. In a framework-managed transaction, a background worker, a webhook handler, a workflow-engine step, you cannot, because you do not own the boundary and committing mid-handler fights the framework. The right tool there is a transactional outbox: write the intent to send inside the transaction, and actually send only after the transaction commits.
What to take from it Link to heading
- Long tokens in email URLs are fragile. MIME quoted-printable wrap seams at column 76 are a real corruption vector, and some providers will mangle the escape that lands on one. I would keep anything load-bearing in a URL short enough that it cannot reach the first seam.
- “Signed, not encrypted” is worth internalising. A signature stops tampering; it does not hide anything. Putting claims in a URL leaks them to anyone who can base64-decode.
- Turning a stateless credential into a stateful one is not a free trade. The persisted state must be committed before any external side effect that references it. Request transactions can commit-before-send; framework-managed transactions need an outbox.
- When a 404 is deterministic per recipient, suspect the content of the URL, not the infrastructure. Compare what you sent with what was clicked, check the signature and the column of the difference, and the wrap seam gives itself away.