· 5 min read
Why We Replaced Magic Links with One-Time Codes
Embarking on a freelance career in tech is an exciting journey that can open the door to endless opportunities. Whether you’re a college student, a self-taught coder, or a seasoned professional, the road to becoming a successful freelancer is as unique as you are.
Magic link authentication looks elegant on the surface. The user enters their email, clicks a link, and they’re signed in — no password to remember, no friction. But the elegance hides a structural weakness: the credential that authenticates the user is embedded directly in a URL.
That single design choice creates a surprising amount of exposure. URLs get written to web server access logs, CDN logs, and proxy logs by default. They get cached in browser history. They get accidentally forwarded when a user shares “the email” with a colleague or pastes a screenshot into a support ticket. None of this requires a sophisticated attacker — it just requires the token to exist somewhere a link can travel, and links travel further than anyone expects.
We recently migrated the authentication flow on recrutamentoetico.pt away from magic links and toward six-digit one-time codes — the same pattern used by Stripe, GitHub, Slack, and most modern SaaS products. Here’s why we made the change and how we built it.
The trigger for this one was less “incident” and more “homework.” I’ve been spending more time lately reading into application security — going deeper on the kinds of issues that don’t show up until you’re specifically looking for them — and the magic link flow surfaced as an obvious candidate for improvement once I started looking at the project through that lens. Sometimes the best security work doesn’t come from a breach or an audit; it comes from getting a bit more curious about how things actually behave.
The problem with putting secrets in URLs
A magic link is, functionally, a bearer token wearing a disguise. Whoever holds the link is authenticated — no password, no second factor, nothing else required. That’s fine as long as the link stays private. The trouble is that URLs aren’t designed to stay private:
- They get logged. Most infrastructure logs full request URLs by default, including query parameters. A token in the URL is a token in your logs.
- They get cached. Browser history, email previews, and link-unfurling bots in chat apps will all touch the link before the intended user does.
- They get shared. Forwarding an email forwards the login token. Most users don’t think of “forward” as “hand someone my session.”
None of these are exotic attack vectors. They’re just consequences of how the web already works, which is precisely why they’re easy to miss in a design review and easy to exploit afterward.
A short numeric code changes the threat model entirely. It’s delivered over the same channel (email) but consumed differently — typed by hand into a form, never embedded in a clickable link, never present in a URL for any system to log. The secret only ever exists in a POST body and a database row.
What we built
The migration touched the full stack, from schema to UI.
Data layer. We added an AuthCode model to track codes server-side, replacing what had previously been ephemeral in-memory storage. Persisting codes to the database — rather than keeping them in memory — gave us a natural place to enforce expiration, attempt counts, and single-use semantics as data constraints rather than as application logic scattered across the codebase.
Service layer. requestMagicLink() and verifyMagicLink() were replaced with requestAuthCode() and verifyAuthCode().
- Rate limiting: X codes per email per hour
- Attempt limiting: X failed verification attempts per code before it’s invalidated
- Expiration: X-minute validity window
- Single use: a code is marked consumed the moment it’s successfully verified
API surface. The verification endpoint now accepts an email and a six-digit code instead of a bearer token — a small but deliberate signal that the credential is now something a human types, not something a link carries.
Email and frontend. We built a new transactional email template that surfaces the code clearly, with a short note reminding users not to share it. On the client, login became a two-step flow: enter your email, then enter the code you receive. The code input uses a monospace font to make six digits easy to scan, and a “back” action lets users restart with a different email without reloading the page. The old verify-by-link route and its component were removed outright, since there’s no longer a token-bearing URL for it to handle.
What this buys us
| Magic link | One-time code | |
|---|---|---|
| Where the secret lives | Embedded in a URL | Typed into a form field |
| Log exposure | Logged by default in most infra | Never appears in a URL |
| Forwarding risk | Forwarding the email forwards access | Code is useless without the matching email + short window |
| Request limits | Often unbounded | Capped at X/hour |
| Verification limits | N/A (link either works or doesn’t) | Capped at X attempts/code |
| Storage | In-memory, ephemeral | Persisted, auditable |
The net effect is a login flow that’s no less convenient for users — they still just check their email — but meaningfully harder to compromise through the ordinary, boring failure modes that affect most infrastructure: logs that live longer than anyone planned, links that get forwarded for the wrong reasons, and tokens that sit in places they were never designed to be read.
Takeaway
None of the individual pieces here are novel — six-digit codes, rate limiting, attempt caps, short expirations — they’re a known pattern. What’s worth internalizing is the underlying principle: secrets belong in places designed to keep secrets, like form submissions and encrypted database rows, not in places — like URLs — whose entire purpose is to be passed around, logged, and shared.
If your auth flow puts a token in a link, it’s worth asking where else that link might end up.
For recrutamentoetico.pt, this was a good reminder that AppSec improvements don’t have to wait for a pentest finding or a CVE. A bit of deliberate reading and a willingness to revisit “working” code with fresh eyes was enough to spot — and fix — a real gap.