Back to engineering

Engineering

Magic links done right: why a login token is not a share link

The Bookatu engineering team7 min read

Two things in Bookatu look identical from a browser address bar: the passwordless login link and the gallery share link. They must never be the same token. Here is how we sign, scope, and expire them, with a sign and verify sketch.

The short version

  • A login token and a share link look the same in the address bar, but they grant very different power. Keep them as separate token types.
  • Sign a small payload with HMAC-SHA256, put the expiry inside the signed data, and verify with a constant-time compare.
  • Login tokens are short-lived and single-use. Gallery share links are long-lived and read-only for one gallery.
  • Never reuse a login token as the link you paste into WhatsApp. Different lifetime, different scope, different blast radius.
  • Scope every token to one action and one resource, so a leaked link cannot do more than it was meant to.

Bookatu has two features that put a long random string into a URL. The client portal uses passwordless magic links, so a client can manage a booking without a password. The photographer vertical uses share links for private galleries, so a client can open their photos from an email or a chat message. Both look like one opaque link. They are not the same thing, and treating them as the same is how login systems leak.

Two tokens that look identical and are not

A login magic link proves who you are. Click it and you hold a session. From there you can see contact details, change a booking, and act as that person. That is a lot of power, so the link has to be cheap to issue and expensive to abuse. The OWASP Authentication Cheat Sheet is direct about this: tokens should be unpredictable, short-lived, and single-use, and the server must reject anything expired.

A gallery share link proves nothing about identity. It grants read access to one gallery of watermarked, low-resolution previews. In Bookatu the photographer uploads high-res, and the platform auto-generates a 1080px, EXIF-stripped, baked-in-watermark preview. The share link only ever opens that preview set. It does not unlock full-resolution files, and it cannot touch another client's gallery. Because clients paste these links into WhatsApp and email threads, the link has to survive for weeks, so its lifetime is measured in weeks, not minutes.

Put those two facts side by side. One token must die quickly because it is a key to an account. The other must live for weeks because it is a doorway to one low-stakes, read-only room. If you ever issue a single token type and use it for both, you are forced to pick one lifetime, and both choices are wrong. A login link short enough to be safe gets pasted into a chat and stops working before anyone opens it. A login link long enough to share is a weeks-long account takeover waiting on a forward.

The expiry and the scope are not link metadata you store on the side. They are part of the signed payload. If they are not signed, they are suggestions, and an attacker edits suggestions.

Sign the payload, including the expiry

The pattern is the same for both token types: build a small payload, attach an expiry, sign the whole thing with HMAC-SHA256, and base64url-encode the result. On the way back in, recompute the signature and compare it in constant time before you trust a single field. Signing the expiry inside the payload is the part people skip. If the timestamp lives only in a query string and not under the signature, the client can move it. Cyril Kato's write-up on HMAC URL protection makes the same point: embed the expiry in the signed data so it cannot be modified without breaking the signature.

typescript
import { createHmac, timingSafeEqual } from "node:crypto";

// Conceptual sketch. The payload carries the token's type, its subject
// (a user or a gallery), and its expiry -- all INSIDE the signed bytes.
function sign(payload: object, secret: string): string {
  const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
  const sig = createHmac("sha256", secret).update(body).digest("base64url");
  return `${body}.${sig}`;
}

function verify(token: string, secret: string) {
  const [body, sig] = token.split(".");
  if (!body || !sig) return null;

  const expected = createHmac("sha256", secret).update(body).digest();
  const given = Buffer.from(sig, "base64url");

  // constant-time compare; never use === on signatures
  if (given.length !== expected.length) return null;
  if (!timingSafeEqual(given, expected)) return null;

  const claims = JSON.parse(Buffer.from(body, "base64url").toString());
  if (claims.expiresAt < Math.floor(Date.now() / 1000)) return null; // expired
  return claims;
}

Two details in that sketch carry most of the weight. The compare uses timingSafeEqual, not ===. A naive string compare returns on the first wrong byte, and that timing difference lets an attacker recover a signature one byte at a time. The expiry check happens after the signature check, so you never parse claims from a token you have not verified. Cheap integer comparison can run first to reject obviously stale tokens, but the trust boundary is the HMAC.

Scope is the other half

A signature proves the token came from us. It does not say what the token is allowed to do. That is what the type and subject in the payload are for. A login token verifies, declares itself a login token, and starts a session for that user. A gallery token verifies, declares itself a gallery token, and opens exactly that gallery's previews. The verifier refuses to cross the streams. A gallery token presented to the login route is rejected on its type, even though the signature is perfectly valid. This is least privilege applied to links, the same principle the OWASP Secrets Management Cheat Sheet pushes for any credential: grant the narrow thing, not the broad thing.

Single-use is what separates the two on revocation. A login token carries a unique id that is recorded and burned the moment it is used, so a forwarded or replayed login link is dead on the second click. Auth0's token guidance lands in the same place: keep authentication tokens short-lived and treat reuse as a signal, not a feature. Gallery links are deliberately reusable, because the client will open them many times over the weeks they live. That is fine, because the worst case for a leaked gallery link is a stranger seeing watermarked thumbnails. The full-resolution files never sit behind it. They unlock only after the included or paid set is settled, through a separate flow.

What we keep boring on purpose

  • One secret per token type, rotated on a schedule, with rotation handled so live links do not break.
  • Expiry always inside the signed payload, never only in the query string.
  • HMAC-SHA256 or stronger. No SHA1, no MD5, no homegrown compare.
  • Login tokens: short lifetime, single-use, full session on success.
  • Gallery links: long-lived, read-only, one gallery, watermarked previews only.
  • Clear errors. Expired, already used, and invalid are three different messages, so a real client knows whether to request a fresh link or stop trying.

None of this is exotic. It is a small signed payload, an expiry under the signature, a constant-time compare, and a hard line between a token that logs you in and a token that opens one gallery. The mistake is never the cryptography. It is reaching for the login token because it is already there and pasting it somewhere it was never meant to live.

Sources

  • OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
  • OWASP Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
  • Auth0 Docs: Token Best Practices: https://auth0.com/docs/secure/tokens/token-best-practices
  • URL Protection Through HMAC: A Practical Approach (Cyril Kato): https://blog.cyril.email/posts/2025-03-12/url-protection-through-hmac.html
securityauthenticationmagic-linkshmactokensengineering

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs