DEV Community

Nabeel Hassan
Nabeel Hassan

Posted on

My Login Link and My Unsubscribe Link Are the Same Kind of String

Upwork Scout has no password field. It never had one. When I was sketching the signup flow I told myself I was removing work: no hashing, no reset flow, no rules about symbols and capital letters, no breach surface built out of passwords people reused from somewhere else.

That held for about a week. Then I looked at what I had actually done. I had not removed the work. I had moved all of it into one file of about fifty lines that signs three completely different things with the same secret.

This is what passwordless auth looks like once it is actually running, using the job alert tool I run at upwork-scout.com as the example. Small Next.js app, Firestore behind it, and the entire auth layer is lib/auth.ts.

Three links that are the same object

A user gets three kinds of credential from me over a normal month:

  1. A login link, emailed on request, which should work for about fifteen minutes.
  2. A session, which is not a link but a cookie, and should last a month.
  3. An unsubscribe link, which sits in the footer of every alert email and needs to work roughly forever, because forever is how long an old email stays in an inbox.

All three are signed JWTs. All three come out of the same secret. This is the whole signing side:

type Purpose = "magic" | "session" | "unsub";

export async function signToken(uid: string, purpose: Purpose, expiresIn: string) {
  return new SignJWT({ uid, purpose })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime(expiresIn)
    .sign(SECRET());
}
Enter fullscreen mode Exit fullscreen mode

Two claims of my own: a user id and a word. That word is carrying more weight than anything else in the application.

The line that keeps them apart

Verification takes the purpose it expects as an argument and refuses everything else:

export async function verifyToken(token: string, purpose: Purpose) {
  try {
    const { payload } = await jwtVerify(token, SECRET());
    if (payload.purpose !== purpose || typeof payload.uid !== "string") return null;
    return payload.uid;
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Delete payload.purpose !== purpose and nothing appears to break. The build is green. Login works. Unsubscribe works. The app is also completely broken, because every alert email I have ever sent contains a valid, year-long, uid-bearing token in a query string, and the session reader would now happily accept one.

That is the part that genuinely surprised me about going passwordless. The risk is not that a token might leak. I am putting tokens into email footers deliberately, thousands of times, as the normal operation of the product. The only reason a footer link is not a login is one string comparison. A password-based app has no sentence like that in it anywhere.

So the rule I came away with: a verifier never accepts a token, it accepts a token for something. Purpose is not metadata attached to the credential. Purpose is the type of the credential, and the check belongs in the one function nobody can route around.

The lifetimes are the design

Once purpose is a type, each one gets its own expiry, and the three numbers are not close to each other:

signToken(uid, "magic",   "15m");  // emailed on request
signToken(uid, "session", "30d");  // httpOnly cookie
signToken(uid, "unsub",   "365d"); // footer of every alert
Enter fullscreen mode Exit fullscreen mode

Fifteen minutes on the magic link is not really about attackers. It is about inboxes being shared, forwarded, synced to an old laptop, and left open. A login link is the one credential in the system that travels through infrastructure I do not control, so it gets the shortest life I can give it without making a slow email useless.

The 365 day one is where I argued with myself the longest. A year is an uncomfortable time for a signed credential to sit somewhere I cannot reach. But think about what a short unsubscribe token actually does: a person who wants my email to stop clicks the link, sees "this link is invalid or expired", and reports the message as spam instead. That is worse for everyone, including the people who do want the alerts. The unsubscribe token gets the longest life of anything in the system because the failure mode of a short one is a deliverability problem, and deliverability is the product.

The user id is not a secret, and I stopped pretending

/** Deterministic user id from email (stable, no lookup needed). */
export function uidForEmail(email: string): string {
  return Buffer.from(email.trim().toLowerCase()).toString("base64url").slice(0, 60);
}
Enter fullscreen mode Exit fullscreen mode

That is the entire user id. It is the email address wearing a hat. It is deterministic, it needs no round trip, and the document key falls out of the signup form before I have touched the database.

I still like it, and I want to be honest about the cost. Anyone holding a uid can decode an email out of it, because base64url is an encoding and not a hash. So the uid can never be the authorization. It is a name, not a key. Everything that matters reads the uid out of a signed token and then confirms the document exists:

const uid = await verifyToken(token, "magic");
if (!uid) return NextResponse.redirect(`${base}/?error=expired`);

const snap = await usersCol().doc(uid).get();
if (!snap.exists) return NextResponse.redirect(`${base}/?error=expired`);
Enter fullscreen mode Exit fullscreen mode

Guessing a uid buys nothing, because you cannot sign it. What it does mean is that a raw uid never goes into a URL, a log line, or a response body that is not already gated by something else. Readable identifiers are fine right up until somebody treats one as proof.

The account exists before the person does

With no password, signup and login are the same request. There is no second step where somebody proves they meant it. So requesting a link creates the user document immediately with active: false, and only the first successful verify wakes it up:

if (!snap.get("emailVerifiedAt")) updates.emailVerifiedAt = Date.now();
if (snap.get("active") === false && !snap.get("emailVerifiedAt")) updates.active = true;
Enter fullscreen mode Exit fullscreen mode

The unverified row is how I keep those two ideas apart. It can hold preferences, it can carry counters, it can be rate limited, and it cannot receive a single alert until somebody has proven they can read that inbox. If you type a stranger's address into my form, you create a dormant row and nothing else happens to them.

The rate limit lives on that same document, which I expected to feel like a hack and does not:

const last = snap.get("lastLinkRequestAt") as number | undefined;
if (last && now - last < 60_000) return NextResponse.json({ ... }, { status: 429 });
Enter fullscreen mode Exit fullscreen mode

One link per minute per address. No Redis, no separate limiter, no extra service to keep alive. The thing being protected and the counter protecting it are the same read I was doing anyway.

The part that actually broke was not the auth

Here is what nobody mentions when they recommend deleting the password field. Your mail provider is now your auth provider. Not as an analogy. If mail does not arrive, nobody can get in, ever, and there is no second route because there is no password to fall back to.

I learned this in the least dignified way available. Upwork Scout sends through Resend in production, with an Apify actor sitting behind it for local work. That fallback actor runs with limited permissions and will only deliver to the Apify account owner's own address. Every other recipient fails with "Users can only send an email to their own email address."

So: I tested signup with my own email, many times, and it worked perfectly every time. The auth code was correct. For anyone who was not me, request-link returned a 500 and the product effectively did not exist.

There is now a comment block at the top of the email module, in capitals, saying exactly this. It is the only bug I have shipped whose entire cause was that I only ever tested as myself.

The general version: if your login is an email, then your login has an uptime, a sender reputation, a set of DNS records, and a spam folder. A verified sending domain belongs on the launch checklist next to the database, not under "nice to have later".

The one I still owe

The unsubscribe route is a GET that flips active: false the moment it is hit. One click, no confirmation, which is exactly right for a person who wants out and exactly wrong for the way corporate mail scanners and link previewers fetch every URL in a message before a human sees it. Somebody's security appliance can unsubscribe them from a product they were enjoying.

The fix is not hard and I have not shipped it: keep the one-click promise, but make the state change a POST, which is what the one-click unsubscribe standard the large mailbox providers ask for wants anyway. Writing this paragraph is mostly me making it harder to keep postponing.

The trade, honestly

Passwordless was still right for this product. No password table to breach, no reset flow, no support thread about lockouts, and signup is one field on a landing page.

What I traded for it is a system where the security is concentrated rather than spread out. One secret. One verifier. One string comparison standing between an email footer and a session. Concentrated is genuinely the good part, because there is very little of it and I can hold all of it in my head at once. It is also a surface with no redundancy in it. When the entire gate is fifty lines, every one of those lines earns the kind of reading you would normally save for something much bigger.

Top comments (0)