Skip to content
Lab / Building OkayFlow / 03
EngineeringSecurityBuild in public

"No accounts" is a design problem, not a shortcut

"No login" sounds like less security work. It's more. Here's the token design, rate limiting, and audit trail behind a public client-approval link — and what we'd do differently.

Jul 20, 2026Daniel6 min readOkayFlow

The pitch to the user is "your client doesn't need an account." That's a real benefit — the person reviewing the work opens a link and taps a button, no signup, no password, works on their phone. But "no account" is a UX promise that quietly hands you a security problem, and if you treat it as a shortcut you'll build something you shouldn't attach a receipt to.

Here's the reframe that made us take it seriously: when there's no login, the URL is the credential. Anyone who has the link can act. So every question you'd normally answer with auth — who is this, should they be here, how do I revoke them, how do I not get abused — now has to be answered by the link itself. This is a note on how we did that for OkayFlow's public review portal, and the parts we're still not fully settled on.

The token has to carry the entire security budget

If the link is the credential, the token in it is the whole thing. It has to be long enough that guessing is hopeless, and it can never be enumerable.

ts
import { randomBytes } from "node:crypto";

/** 192-bit unguessable token for public links (URL-safe, 32 chars). */
export function generateToken() {
  return randomBytes(24).toString("base64url");
}

That's the entire generator. Twenty-four bytes of CSPRNG output — 192 bits of entropy — encoded as URL-safe base64, which lands at 32 characters. No sequential IDs anywhere near a public route, no timestamp baked in, nothing you could increment to find your neighbor's project. At 192 bits, guessing isn't a threat model you have to think about; you'd exhaust the sun first.

The boring choices matter as much as the entropy. randomBytes from Node's crypto, not Math.random(). base64url, so the token survives being copied, pasted, and shoved into a URL without escaping. Short enough that the whole link is still something a person can send in a text message without it wrapping into garbage.

Revocation is the part "no accounts" makes tempting to skip

With accounts, revoking access is a solved problem — disable the user. Without accounts, you have to build it, and it's easy to tell yourself you don't need to. You do. Links leak. They get forwarded to the wrong person, pasted in the wrong channel, screenshotted with the address bar showing.

So a link can be revoked or regenerated, and regenerating just mints a fresh token and orphans the old one:

ts
export async function regeneratePublicLink(conn, projectId) {
  // ... writes a new publicToken: generateToken(), audits "link.regenerated"
}

The subtle part is what revocation must not break. If revoking the review link also killed the approval receipts that link produced, revocation would be dangerous — you'd be destroying the freelancer's proof of completed work in order to lock out a stale URL. So the receipt gets its own public token, deliberately separate from the project's review token. Revoke the review link all you want; every receipt it already issued stays reachable at its own address, because a receipt is read-only proof of work that's already done.

Rate limits, and the decision to fail closed

An unauthenticated endpoint is an open invitation to hammer it. There's no account to lock, so the throttle is your only lever. We run two sliding-window limits on the portal, keyed differently so one can't be dodged by rotating the other: one per link token, which caps abuse of any single link, and one per client IP, which caps someone spraying across many links from one place.

The design decision worth calling out isn't the numbers, it's the failure mode. The limiter runs on Upstash Redis in production. What happens when Redis is unreachable? You get two choices: fail open (let requests through when you can't check) or fail closed (reject when you can't check). The convenient choice is fail-open — an outage in your rate limiter shouldn't take down your app. We chose fail closed for the abuse-prone endpoints anyway:

ts
publicPortal:   { max: 20, windowMs: 10 * 60 * 1000, failMode: "closed" },
publicPortalIp: { max: 60, windowMs: 10 * 60 * 1000, failMode: "closed" },

The reasoning: for a public, cost-and-abuse-bearing route, "I can't verify you're within limits" should mean "then you don't get through," not "then everyone gets through unlimited." A brief, honest failure beats a silent window where the throttle is off and nobody knows. In local dev and tests there's an in-memory fallback, so you're not required to run Redis to work on the thing — but production, when it genuinely can't check, closes the door and logs a security.rate_limit_unavailable event so it's visible rather than silent.

Worth noting this is the opposite call from the one we made on BurnCap, where the budget check fails open on purpose — because there, the limiter sits in a customer's production hot path and a monitoring outage must never take down their app. Same question, different blast radius, opposite answer. The failure mode should follow what breaks when you're wrong.

An audit trail that doesn't hoard personal data

A receipt is only as trustworthy as the record behind it, so approvals get audited — but the client is a non-user who never signed up, and we don't want to be sitting on a pile of their raw IP addresses. The compromise is a keyed one-way fingerprint instead of a stored IP:

ts
export function hashSensitiveIdentifier(value: string) {
  const key = process.env.BETTER_AUTH_SECRET ?? "…local-dev-fallback…";
  return createHmac("sha256", key).update(value).digest("hex").slice(0, 32);
}

It's HMAC-SHA256, not a bare hash — keyed with the app's server secret. That matters: IPv4 has a small enough space that plain sha256(ip) is trivially reversible with a lookup table. Keying it means you can't precompute the space without the secret, and truncating to 32 hex characters keeps it useful for "were these two approvals from the same origin?" without pretending to be a forensic identifier. It's enough to detect obvious abuse patterns and to give a receipt a consistent, non-reversible origin marker; it's deliberately not enough to deanonymize anyone. The same audit row stores the user-agent and a structured action, so there's a real trail — just not a creepy one.

What we'd do differently

Two honest gaps. First, the tokens don't expire on their own — a review link is valid until it's revoked. For this product that's arguably correct (a client might come back to approve weeks later), but "no natural expiry" is a decision you should make on purpose, not discover later. If we were doing magic-login links instead of long-lived review links, they'd have a TTL.

Second, the two rate-limit keys (token and IP) are independent, which means a determined abuser with many IPs and many links has more room than a single combined key would allow. That's a deliberate trade against false positives for legitimate clients on shared networks, but it is a trade — and if abuse ever showed up, it's the first knob we'd revisit.

The larger point holds up, though: "no login" is not less security work, it's different security work, and usually more of it. You're moving the whole burden onto the artifact you hand out.

This is the kind of unglamorous plumbing that most of OkayFlow is made of, and honestly the part we enjoy most. The receipt is what users see; the token design is what lets us stand behind it.