The defaults are easy because someone made them that way. Firebase, Supabase, Auth0, Google Sign-In. These solutions are well-documented, widely used, and take about twenty minutes to integrate. That convenience is real, but so is the tradeoff for your users.
I built 404's account system without any of them. The site is fast, the auth works, it (should) scale, and it has been in production since launch. The stack is a Cloudflare Worker, a D1 database, and a self-hosted email API (Resend or Mailgun works here). The total cost at current scale is zero dollars. I am not saying this to be contrarian. I am saying it because the gap between "industry standard" and "good enough for production" is smaller than the defaults make it seem.
The total cost at current scale is zero dollars.
For 404 this matters a lot. We exist because websites collect your data without your knowledge, and that data ends up in places you never anticipated. Building Google Sign-In into the 404 account page would have meant that every time a user authenticates, Google learns who they are, when they showed up, and what device and network they came from. Google does not do anything obviously malicious with that. But they have it.
I was already using Cloudflare's KV storage for a waitlist when I started thinking through accounts properly. I read through privacy policies for a few auth providers, looked at self-hosted options, and kept running into the same problem: every provider worth using required trusting a third party with data I did not want to share. The alternative was building something small enough to understand completely. That turned out to be less work than I expected.
This post covers the system I ended up with. It handles magic link auth, license verification, authenticated downloads, and desktop app auth handoff. It is not novel. It is just a set of deliberate choices about who does and does not need to be in your users' trust chain.
The zero-cost privacy stack for user authentication
- Compute: Cloudflare Workers (edge runtime)
- Database: Cloudflare D1 (SQLite at the edge)
- Auth: Custom JWT + magic links
- Email: Resend (transactional)
- Storage: Cloudflare R2 (w/ authenticated downloads)
What I actually needed
Before deciding on account infrastructure, I wrote down what an "account" actually requires for this product.
A user needed a way to prove they paid and the desktop app needed a way to verify that proof at launch. That is pretty much it. No profile to store, no preferences to sync, no social graph to build. The minimum viable data structure was an email address, a license key, and a boolean that said whether that key was free or paid.
Everything else, password hashing, session tokens, refresh token rotation, magic link expiry, is implementation detail around those three fields.
Once I framed it that way, the heavyweight auth providers started to look like what they are: products designed for apps that need much more than most products do. Firebase Auth handles multi-tenancy and role-based permissions and OAuth federation. I needed one table with three columns.
The serverless auth stack: why I chose D1 and Workers over Auth0
Everything runs on Cloudflare's free tier.
Cloudflare Workers handle all request logic: magic link generation, JWT issuance, and license verification. No server to maintain, no passwords to store, minimal downtime concerns, and automatic global distribution.
D1 is Cloudflare's SQLite-at-edge database. One table. Three columns: email, license_key, active. That is the entire schema.
Resend handles transactional email for now. It is the easiest option at zero revenue. When that changes, swapping the email provider is a one-line change in the Worker. There is Cloudflare Worker specific setup here.
R2 is Cloudflare's object storage, where the actual installer files live. Downloads are served through an authenticated Worker route, not a public URL. A valid JWT with an active license gets a signed response from R2. Without one, there is nothing to request.
Stripe webhooks are the only external data input. When a purchase completes, Stripe sends a webhook to the Worker, which sets active = true on the corresponding license.
No Google. No Auth0. No Supabase. No company watching my users authenticate.
Implementing passwordless magic links with JWTs
The flow is passwordless. There are no passwords to store, which means there is no password database to breach.
A user visits the account page and enters their email. The Worker checks D1. If that email exists, it generates a signed, time-limited token, embeds it in a magic link, and sends it via Resend. The user clicks the link. The Worker validates the token, and if it is valid, issues a JWT signed with a secret stored in the Worker's environment variables using the Web Crypto API.
localStorage vs cookies: The JWT is stored in localStorage rather than an HttpOnly cookie. The tradeoff is deliberate. An HttpOnly cookie prevents JavaScript from reading the token, which limits XSS exposure, but it also means the browser sends it automatically on every request, including cross-origin ones, which requires careful CSRF handling. Since the account page is a single origin and the Worker validates the Authorization header explicitly, localStorage with a short expiry is the simpler and adequate choice here. If your use case involves more sensitive data or multiple origins, cookies with HttpOnly and SameSite=Strict are worth the added complexity.
When the user clicks the magic link, the client parses the token from the URL, hits the Worker's verify endpoint, stores the JWT, and cleans the URL:
// Client-side
async function handleTokenFromUrl(): Promise<TokenHandlingResult> {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
if (!token) return { handled: false };
const res = await fetch(`${WORKER}/auth/verify?token=${token}`);
const data = await res.json() as unknown;
const verifyPayload = isRecord(data) ? data : {};
const jwt = toOptionalString(verifyPayload.token);
if (!res.ok || !jwt) {
showMessage('This login link is invalid or has expired. Please request a new one.', 'error');
return { handled: true };
}
localStorage.setItem(TOKEN_KEY, jwt);
window.history.replaceState({}, '', '/account');
return { handled: false, snapshot: normalizeAccountSnapshot(verifyPayload) };
}
On subsequent page loads, before making any network request, the client decodes the JWT payload and checks the exp claim locally. If the token is already expired, it clears localStorage and shows the login view without a round trip to the Worker:
// Client-side
async function loadDashboard(jwt: string) {
const payload = decodeJwtPayload(jwt) as Record<string, unknown> | null;
if (!payload) {
localStorage.removeItem(TOKEN_KEY);
show('login-view');
return;
}
const now = Math.floor(Date.now() / 1000);
const tokenExpiry = toOptionalNumber(payload.exp);
if (tokenExpiry && now >= tokenExpiry) {
localStorage.removeItem(TOKEN_KEY);
show('login-view');
showMessage('Your session has expired. Please sign in again.', 'error');
return;
}
// Token looks valid locally — now fetch the account snapshot
}
The account snapshot fetch tries the Worker endpoint and falls back gracefully if the response shape is unexpected. A 401 surfaces immediately as a session expiry. Anything else falls back to whatever the JWT payload contains:
// Client-side
async function fetchAccountSnapshot(jwt: string, fallbackPayload: Record<string, unknown>) {
const res = await fetch(`${WORKER}/account`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${jwt}`,
'Accept': 'application/json',
},
});
if (!res.ok) {
if (res.status === 401) throw new Error('Session expired. Please sign in again.');
return normalizeAccountSnapshot(fallbackPayload);
}
const data = await res.json() as unknown;
return { ...normalizeAccountSnapshot(fallbackPayload), ...normalizeAccountSnapshot(data) };
}
The Worker response is a normalized snapshot of the account state. Keeping it typed on the client prevents the dashboard from rendering stale or malformed data if the Worker response shape ever changes:
interface AccountSnapshot {
email?: string;
plan?: string | null;
expires_at?: number | null;
renews_at?: number | null;
license_valid?: boolean | null;
billing_active?: boolean | null;
has_license?: boolean | null;
download_url?: string | null;
}
The dashboard renders based on what comes back. If license_valid is true, the user gets a platform-aware download menu. If not, they get a purchase prompt. The Worker is the authority on license state. The client just reads it.
Desktop auth is a separate flow
Desktop apps cannot authenticate the same way a browser-based account page can. There is no localStorage to persist a session across launches, and magic links present a specific problem: email clients, security scanners, and link preview systems routinely fetch URLs before the user clicks them, which burns the token before it can be used. OAuth with PKCE solves some of this, but adds a provider dependency for the same reasons I was trying to avoid.
The approach I use is a browser confirmation handoff. The desktop app has its own sign-in screen with an email input. The user requests a magic link from inside the app. The link opens a short confirmation page in their default browser rather than completing authentication directly, which prevents scanners from burning the token on prefetch. The browser page validates the session, and the app polls an exchange endpoint to retrieve a JWT using a one-time code passed back via a custom URL scheme. The app stores that JWT locally and sends it as a Bearer token to /validate-session on every launch.
The three Worker routes that handle this are /auth/desktop/request, /auth/desktop/verify, and /auth/desktop/exchange. From the database's perspective, a browser session and a desktop session are identical once the JWT is issued. The handoff complexity lives entirely in the auth flow, not in the license model.
One thing worth being explicit about: the Worker is the authority on license state, but it is not the only verification layer in the system. Desktop app updates are verified independently through Tauri's updater signature and Sigstore provenance from the build workflow. A compromised Worker cannot push a malicious update undetected, because the app verifies binary integrity before applying anything. Auth and update integrity are intentionally separate concerns, and that separation is by design.
The Worker license check itself is a few lines:
// Worker-side
async function checkLicense(jwt, env) {
const payload = await verifyJWT(jwt, env.JWT_SECRET);
if (!payload) return { valid: false };
const result = await env.DB.prepare(
"SELECT active FROM licenses WHERE email = ?"
).bind(payload.email).first();
return { valid: result?.active === 1 };
}
The JWT secret lives in env.JWT_SECRET, set in the Cloudflare dashboard. It never touches the codebase.
Why this is enough
The question I get when I describe this is usually: "but what about X?" Where X is session invalidation, or token rotation, or account recovery.
Session invalidation: JWT expiry handles the common case. If I need to invalidate a specific user's session immediately, I can flip their license to inactive, which the Worker checks on every request. It is not a perfect revocation system, but it is adequate for this use case. That is a real operational cost to know going in.
Token rotation: for a product where the sensitive action is downloading software and verifying a license, the attack surface from non-rotating JWTs is small. The JWT proves you paid. If someone's JWT is stolen, the attacker can download the software, which they could also just buy.
Account recovery: the magic link flow is account recovery. If you lose access to your email, that is a problem I cannot solve. Neither can Auth0, without your email.
The point is not that this system is perfect for every use case. It is that it is genuinely adequate for this one, it costs nothing, and it does not route my users through a third-party auth provider's infrastructure.
How to build passwordless auth with Cloudflare Workers, D1, and JWTs
This setup covers a complete edge-first authentication flow: a D1 schema for license storage, magic link generation and single-use token verification, JWT issuance using the Web Crypto API (no external libraries required in Workers), Stripe webhook handling for license activation, and authenticated file delivery from R2. No external auth providers. No per-user fees. You own the data in your own D1 database, deployed globally on Cloudflare's network.
The whole thing deploys in about two hours using wrangler. Here is how it fits together.
A note on security before you start: because there is no OAuth provider acting as a buffer, a few things become your responsibility. Cloudflare's built-in rate limiting handles brute-force attempts on the magic link endpoint. D1's prepare() with bound parameters handles SQL injection. Both are straightforward to configure and are worth setting up before you go to production.
If you would like some assistance setting this up, feel free to reach out at support@404privacy.com. I'm more than willing to help a fellow founder protect their users' privacy.
1. Create the D1 database
See the D1 get started guide for a full walkthrough. The short version:
wrangler d1 create 404-licenses
Add the binding to wrangler.toml. The wrangler configuration docs cover all available binding options:
[[d1_databases]]
binding = "DB"
database_name = "404-licenses"
database_id = "your-database-id"
Create the schema. D1 uses SQLite's SQL semantics, so standard SQLite types and syntax apply:
CREATE TABLE licenses (
email TEXT PRIMARY KEY,
license_key TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE magic_tokens (
token TEXT PRIMARY KEY,
email TEXT NOT NULL,
expiry INTEGER NOT NULL
);
2. Set up the Worker
wrangler generate 404-auth
cd 404-auth
Add your secrets. Cloudflare's secrets documentation covers how these are stored and accessed via env:
wrangler secret put JWT_SECRET
wrangler secret put RESEND_API_KEY
wrangler secret put STRIPE_WEBHOOK_SECRET
Generate a strong JWT_SECRET with openssl rand -base64 32. Rotating this secret later invalidates every active session globally, so generate it once and store it somewhere safe.
3. Magic link generation
// Worker-side
async function sendMagicLink(email, env) {
const token = crypto.randomUUID();
const expiry = Date.now() + 15 * 60 * 1000; // 15 minutes
await env.DB.prepare(
"INSERT OR REPLACE INTO magic_tokens (token, email, expiry) VALUES (?, ?, ?)"
).bind(token, email, expiry).run();
const link = `https://404privacy.com/account/verify?token=${token}`;
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${env.RESEND_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
from: "404 <noreply@404privacy.com>",
to: email,
subject: "Sign in to 404",
html: `<a href="${link}">Click here to sign in</a>. Link expires in 15 minutes.`
})
});
}
4. Token verification and JWT issuance
Tokens are single-use. The Worker deletes the record immediately after validation so the link cannot be replayed:
// Worker-side
async function verifyMagicToken(token, env) {
const record = await env.DB.prepare(
"SELECT email, expiry FROM magic_tokens WHERE token = ?"
).bind(token).first();
if (!record || Date.now() > record.expiry) return null;
await env.DB.prepare(
"DELETE FROM magic_tokens WHERE token = ?"
).bind(token).run();
return record.email;
}
JWT signing at the edge uses the Web Crypto API, which is available natively in Workers without any npm dependencies. One thing worth getting right: JWTs require base64url encoding, not standard base64. The difference is that + becomes -, / becomes _, and padding is stripped. Using plain btoa will produce tokens that break on any payload containing those characters:
// Worker-side (Web Crypto API — no npm dependencies required)
function toBase64Url(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
async function signJWT(email, secret) {
const header = toBase64Url(
new TextEncoder().encode(JSON.stringify({ alg: "HS256", typ: "JWT" }))
);
const payload = toBase64Url(
new TextEncoder().encode(JSON.stringify({
email,
exp: Math.floor(Date.now() / 1000) + (7 * 24 * 60 * 60) // 7 days
}))
);
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(`${header}.${payload}`)
);
return `${header}.${payload}.${toBase64Url(signature)}`;
}
On the client side, decoding the JWT payload to check expiry before making a network request is straightforward. The payload is just base64url-encoded JSON. See RFC 7519 for the full JWT spec if you want to understand what each registered claim means:
function decodeJwtPayload(jwt: string) {
const parts = jwt.split('.');
if (parts.length !== 3) return null;
try {
return JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
} catch {
return null;
}
}
The JWT is stored in localStorage under a fixed key and read on page load. If it is missing or expired, the account page falls back to the login view. Sessions last seven days before requiring re-authentication.
5. Stripe webhook
Stripe signs every webhook with a Stripe-Signature header. Always verify it before processing. The Stripe webhook documentation covers signature verification in detail, including how to retrieve your endpoint secret from the dashboard:
// Worker-side
async function handleStripeWebhook(request, env) {
const sig = request.headers.get("stripe-signature");
const body = await request.text();
const event = await verifyStripeSignature(body, sig, env.STRIPE_WEBHOOK_SECRET);
if (event.type === "checkout.session.completed") {
const email = event.data.object.customer_email;
const licenseKey = crypto.randomUUID();
await env.DB.prepare(
"INSERT OR REPLACE INTO licenses (email, license_key, active) VALUES (?, ?, 1)"
).bind(email, licenseKey).run();
}
}
What this costs
Cloudflare Workers: free up to 100,000 requests per day. D1: free up to 5 million row reads per day and 100,000 writes. Resend: free up to 3,000 emails per month.
For an indie product at early stages, zero dollars, and the usage ceiling is high enough that you will have revenue to pay for upgraded tiers by the time you outgrow the free tier.
The part that matters
None of the code above is novel. Magic links are a well-understood pattern. JWTs are documented everywhere. D1 has good documentation. Cloudflare Workers have been in production long enough that the sharp edges are known.
What I want to push back on is the reflex to reach for Auth0 because it feels like the responsible default. There are alternatives, and they can be secure. Auth0 is not inherently more secure for this use case. It is more convenient for users who already have a Google account. In exchange for that convenience, you are adding Google, or GitHub, or whoever, to the list of parties that know your users exist and when they show up.
For most apps, that trade is fine. For an app whose users are trying to limit what third parties know about them, it is not fine. The auth layer is not exempt from the same analysis you would apply to any other data flow.
Build accordingly.