Authentication is one of those things that looks incredibly simple from the outside, but gets complicated fast the second you start thinking about edge cases.
I recently ran into this while building my app, Grateful. I had a very specific constraint: I wanted to make an ironclad promise to my users that their posts could never be personally traced back to them. However, if you run a social or community app, you know that total unauthenticated anonymity is a recipe for disaster. I still needed a way to recognize returning users so I could maintain a level of control over content moderation.
I wanted a magic link authentication system, but with a twist - users should be able to log in with an email address, but the app should never permanently store that email.
It turns out this isn't a very common pattern. I found a few templates online, but nothing that fit my exact use-case. That led me to roll my own custom implementation of pseudonymous magic link auth.
The core idea is simple. A user enters their email, receives a login link, and gets a session when they click it. But instead of saving that email to a database, the app converts it into a stable, pseudonymous identifier using HMAC-SHA256 and a server-side secret. Grateful can securely recognize returning users and moderate their content, but the database is completely stripped of plaintext email addresses.
After building the auth in my app, I extracted the code and released it on GitHub, should anyone be interested in implementing a similar pattern.
Why I Built It
Most small apps honestly do not need complex authentication. You just need something your users understand, something that keeps you out of the business of storing passwords, and something that isn't a nightmare to deploy.
Magic links are perfect for this. The user doesn't have to remember a password, and I don't have to build password reset flows. The entire transaction boils down to: prove you control this inbox.
But a typical magic link setup still leaves you holding a database full of user emails. That is perfectly fine for most apps, but for Grateful, I wanted to push for a deeply privacy-conscious pattern. I really wanted to answer one question: Can I build a useful, stateful login system without keeping the user’s email in the database?
The answer is yes - but it comes with some serious tradeoffs.
How it Works Under the Hood
When a user submits their email, the app immediately creates an HMAC-SHA256 hash using the email and a secret "pepper" stored in the environment variables. That hash becomes the user’s permanent ID.
The plaintext email is kept alive in memory just long enough to send the magic link, and then it is completely discarded.
The login link itself contains a short-lived JWT with the user's hash, an expiry time, and a unique token ID. When the user clicks the link, the app verifies the token, checks to make sure it hasn't already been used, finds (or creates) the user record based on the hash, and sets a signed session cookie.
The stack pulling this together looks like this:
- Next.js App Router: For the core application structure.
- Server Actions: To handle requesting the login links.
- JWTs: For generating the login tokens and sessions.
- PostgreSQL + Drizzle: To store the user hashes and track consumed tokens.
- Resend: For actually firing off the magic link emails.
- Cloudflare Turnstile: To keep the bots out.
- Middleware: For protecting the authenticated routes.
Basically any of these can be swapped out to a preferred technology, because the core logic is simple and stays the same.
Security Details
Even though this is a small project, I wanted to make sure the auth flow handled the real-world issues that demo projects usually gloss over.
For starters, the login token expires after 15 minutes, and the links are strictly single-use. Each token gets a unique ID (a jti), and the moment a link is clicked, that ID drops into a consumed_tokens table. If someone tries to use that exact same link again, the database rejects the insert, and the request fails. This is critical because magic links are bearer credentials - whoever holds the link holds the keys. Expiry is great, but replay protection guarantees a link is dead after a successful login.
I also layered in rate limiting by both IP address and the email hash. The email-based limit is designed to return a silent success response if triggered, which prevents bad actors from using the form to guess if a specific email belongs to a user.
Why HMAC instead of a regular hash?
Simply, email addresses are highly guessable with a regular hash.
If an attacker ever got their hands on the database, they could take a massive list of leaked or common emails, hash them all, and cross-reference the results. Plain hashes are incredibly vulnerable to offline lookup attacks.
HMAC completely changes the threat model. The hash relies on that secret pepper sitting on the server, which never touches the database. If the database leaks but the secret stays safe, the attacker is left with useless data.
Of course, this makes the server-side secret the most important piece of the puzzle. If I lose it, every single user becomes unreachable, and we start again from zero. Not ideal!
So, if you are interested in using this method or a similar one, I’d recommend backing up the server-side secret to at least 3 places.
The Biggest Tradeoff
The absolute biggest tradeoff of this architecture is that I cannot contact my users. Period.
Because the email isn't stored, there is zero capacity for product updates, billing notices, or password resets. It is a massive limitation, and it's a core architectural decision.
For Grateful, this constraint is actually the main feature. I purposely chose it for privacy, and I have no interest in bugging my users with marketing emails.
But this pattern is definitely not for everyone. It is fantastic for lightweight personal tools, highly sensitive apps, or systems where an email is strictly a disposable key. If your app relies on customer support workflows, team invites, or account recovery, this will not work for you.
Final Thoughts
Not storing emails sounds like a tiny tweak, but it fundamentally shifts your entire product model. You gain a massive reduction in liability and sensitive data, but you sacrifice a ton of operational flexibility.
Building this also gave me a deep appreciation for the gap between "demo auth" and "usable auth." Stitching together a basic magic link is easy. Building a safe one requires expiry, replay protection, bot mitigation, secure cookies, and graceful failure states.
This isn't meant to be a universal solution. It's a template for a very specific kind of app - one where privacy genuinely matters more than being able to shoot an email to your users. For Grateful, trading operational flexibility to guarantee user privacy was the right call, and designing the system around that constraint from day one made all the difference.
By publishing this project, I “prove” to my users that they are pseudonymous, since I use this exact code for my closed-source app, Grateful.