Dev.to Security 🔐 Cybersecurity 👁 0 📖 6 min read

Our whole account system is an email address and 60 bits, and the 32-letter alphabet is why the modulo is not biased

Notifio is a desktop app you buy once. There is no dashboard, no team, nothing to come back to the website for, and no reason for the website to know who you are. So it does not. What a customer holds after paying is tw

Notifio is a desktop app you buy once. There is no dashboard, no team, nothing to come back to the website for, and no reason for the website to know who you are. So it does not.

What a customer holds after paying is two strings: the email address they used at checkout, and an activation token that was emailed to it. That pair is the entire identity system. This is a census of what that choice costs and what it buys, measured rather than asserted.

The token

// server/src/lib/token.ts
/**
 * Generates a human-readable activation token: NTFIO-XXXX-XXXX-XXXX
 * where X is an uppercase alphanumeric character (no ambiguous chars like O/0, I/1).
 */
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";

function randomSegment(length: number): string {
  const bytes = crypto.randomBytes(length);
  return Array.from(bytes)
    .map((b) => ALPHABET[b % ALPHABET.length])
    .join("");
}

export function generateToken(): string {
  return `NTFIO-${randomSegment(4)}-${randomSegment(4)}-${randomSegment(4)}`;
}

Twenty characters, twelve of them random, out of an alphabet of 32. That is five bits per character and 60 bits in total, or about 1.15 × 10^18 tokens. The column is UNIQUE, so a collision is an insert failure rather than two customers sharing a licence, and at 60 bits a collision will not happen.

The alphabet is 32 characters because of the two things a person does with this string: read it off an email, and type it into a desktop app. O and 0 are out, I and 1 are out. A token that gets mistyped once produces a support email, and support email on a £20 product is the whole margin.

Then there is the line ALPHABET[b % ALPHABET.length], which is the textbook shape of modulo bias. crypto.randomBytes gives you 0 to 255. Reduce that modulo an alphabet length that does not divide 256 and the first few characters of the alphabet come up more often than the rest. With 32 characters, 256 / 32 = 8 exactly: every character has eight byte values mapping to it and the distribution is uniform. The code is correct, and it is correct because of a property of the number 32 that nothing in the file mentions.

That is worth saying out loud, because the next person to touch this file has an obvious-looking edit available. Add 0 and O back and the alphabet is 34, 256 / 34 = 7.5, and the first 18 characters of the alphabet become 14 per cent more likely than the last 16. Nothing fails. No test goes red. You lose a fraction of a bit of entropy and gain a bias that only shows up in a histogram nobody is going to plot. The comment above the constant explains which characters are excluded; it does not explain that the count itself is load-bearing. It does now.

What is in the database

Everything above produces one row:

export const licenses = pgTable("licenses", {
  id: text("id").primaryKey(),
  email: text("email").notNull().unique(),
  token: text("token").notNull().unique(),
  stripeSessionId: text("stripe_session_id").notNull().unique(),
  active: boolean("active").notNull().default(true),
  autoReply: boolean("auto_reply").notNull().default(false),
  autoReplySessionId: text("auto_reply_session_id").unique(),
  autoReplyPurchasedAt: timestamp("auto_reply_purchased_at", { withTimezone: true }),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});

Ten columns, one table, four of them unique, and I have written about what those four unique columns are for before. Count what is not there: no password hash, no session table, no refresh token, no email verification flag, no last-login timestamp, no password reset table, no 2FA secret, no OAuth provider column, no devices table. Those nine absences are the actual feature.

The verification is one function:

export async function resolveLicense(email: string, token: string): Promise<License | null> {
  if (!email || !token) return null;
  const [license] = await db.select().from(licenses)
    .where(eq(licenses.token, token)).limit(1);
  if (!license || license.email !== email || !license.active) return null;
  return license;
}

The lookup is by token, and the email is checked afterwards in application code rather than in the WHERE clause, which is deliberate: there is exactly one return value for "no such token", "wrong email for this token" and "licence deactivated", and it is null. The caller cannot tell them apart, so neither can anybody holding a list of email addresses.

The one route that calls it adds the other half of that property:

const ip = req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "unknown";
const { success } = await ratelimit.limit(ip);
if (!success) return NextResponse.json({ valid: false, error: "Too many requests." }, { status: 429 });

A rate limit per IP in front of a 60-bit secret, with a uniform failure response behind it. There is no username to enumerate, because the username is an email address that only ever appears alongside a token that cannot be guessed.

The same response carries something unrelated and useful: the price of the auto-reply upgrade in the visitor's own currency, resolved exactly as the upgrade checkout will resolve it. The licence check is also the price quote, which saves the app a second round trip and guarantees the figure it shows is the figure it will charge.

What the website looks like as a result

I crawled all 38 live pages of the site and counted the parts of a page that normally exist to carry identity:

Count
Pages 38
<form> elements 2
Input fields, including the textarea 5
type="password" inputs 0
Set-Cookie headers on any response 0
Pages with a sign-in link 0

The two forms are the checkout email box on /pricing and the contact form on /help. That is the whole interactive surface of the site.

Zero cookies is why there is no consent banner, which I wrote about in nothing on our landing page talks to another domain and then had to correct in one line in the root layout had been doing it all along. The absence of accounts is upstream of all of it. A site with no session has nothing to keep in a cookie, nothing to protect with a CSRF token, no logged-in variant of any page to keep out of a CDN, and no logged-out state to render differently. Thirty-five of the 38 pages are prerendered and cached as a result.

The bill

This is not free, and the bill arrives in one place: recovery is a human being.

Here is the entire recovery path, quoted from our own /help page:

I lost my activation token. What do I do?
Search your email for a message from [email protected] with the subject "Your activation code". If you cannot find it, contact us below and we will resend it.

There is no "forgot my token" button, because the thing a forgotten-password flow does is prove you control an email address, and if you control the email address you already have the token in it. What is left is the case where the mailbox is gone, and there the identity is gone too, because the email address is the identity. That case gets answered by me reading a contact form submission.

The second part of the bill is revocation. There is no device list and no deactivate button. The app holds its entitlement locally once activated, and the operator-side tool for changing any of this is one Node script that writes the same table, which I wrote about in our free-licence script prints a token on every run.

The honest summary is that this design is correct for exactly one product shape: a one-time purchase, no shared state, no per-seat anything, nothing that renews. The moment there is a subscription, the licence stops being a fact and becomes a state with a timeline, and the moment there are teams there is a second identity to model. Until then, nine absent tables is the right amount of auth, and the hardest engineering decision in the whole system is the length of an alphabet.

The app is on /download, what we store is on /privacy, and the two forms mentioned above are at /pricing and /help.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.