DEV Community

Sike Ren
Sike Ren

Posted on

No accounts meant no license server — so my $19 one-time SaaS signs its keys with ECDSA P-256

I made a product promise I didn't think through: no accounts, no subscription, $19 one-time. The product is Resbetter — paste your resume, get the interview questions it will trigger, with STAR answers already built. The "no accounts" promise made sense for privacy (there's nothing to breach if there's nothing stored). It also closed a door: with no user table, there is no server-side place to record "this person paid". Which means a $19 license key has to prove itself without any server lookup — and a key that works offline is a key someone can study, patch around, and share.

Here's the architecture that fell out of that constraint.

The constraint chain

  1. No accounts → no entitlement store on the server → the key must self-verify.
  2. Privacy by construction → the resume is parsed in the browser, only plain text is ever uploaded, held ≤24h, then deleted → there's no user profile to attach a key to.
  3. One-time $19 → no recurring billing → no subscription token to check on every request.

The key had to be a self-contained proof: something only I can produce, that the browser can check offline, that the server can check again.

Signed keys, not random strings

A random "XXXX-XXXX-XXXX" license key is checkable server-side only if the server has a list. An asymmetric signature is checkable anywhere — the public half ships in the bundle, the private half never leaves my machine.

The key format is deliberately boring:

"RN1-" + base64url( version(1 byte) || serial(uint32 BE) || ECDSA-P256-SHA256 signature(64 bytes) )
Enter fullscreen mode Exit fullscreen mode

5 bytes of payload, 64 bytes of signature. Nothing clever — the cleverness would be in the tooling around it.

Keys are minted offline with a small Node script, using node:crypto (which wraps OpenSSL, so no extra dependency):

import { webcrypto as crypto } from 'node:crypto'

const VERSION = 1
const PAYLOAD_BYTES = 5
const PREFIX = 'RN1-'

function payloadFor(serial) {
  const payload = new Uint8Array(PAYLOAD_BYTES)
  payload[0] = VERSION
  new DataView(payload.buffer).setUint32(1, serial, false)
  return payload
}

const pair = await crypto.subtle.generateKey(
  { name: 'ECDSA', namedCurve: 'P-256' },
  true,
  ['sign', 'verify'],
)
const privateKey = pair.privateKey
// privateKey is exported as JWK and written to .secrets/licence-key.jwk
// (mode 0600). It is the one file whose loss cannot be recovered from:
// without it, no further key can ever be signed.

for (let serial = 1; serial <= 200; serial += 1) {
  const payload = payloadFor(serial)
  const signature = await crypto.subtle.sign(
    { name: 'ECDSA', hash: 'SHA-256' },
    privateKey,
    payload,
  )
  const bytes = new Uint8Array(PAYLOAD_BYTES + 64)
  bytes.set(payload, 0)
  bytes.set(new Uint8Array(signature), PAYLOAD_BYTES)
  console.log(PREFIX + Buffer.from(bytes).toString('base64url'))
}
Enter fullscreen mode Exit fullscreen mode

The serial isn't a secret — it's the identifier. It's the only stable handle the server has for "this key", which is how a redemption ledger can count off it.

Verifying in the browser, network off

The client bundles the public key — 65 bytes, safe to ship:

export const LICENSE_PUBLIC_KEY =
  "BDHuQN7mU6QpfyASU9h15DW1R9UNdvRV5F7XZdpOKRYt2ShXhM0IugtJ6HQ2NSJOmVB3z9Om_t8L8VV_Ci-btSc"
Enter fullscreen mode Exit fullscreen mode

Verification is stock Web Crypto:

const key = rawKey.replace(/\s+/g, "")
const body = key.slice(0, 5).toUpperCase() === "RN1-" ? key.slice(5) : null
if (!body) return null

const bytes = fromBase64Url(body)
if (!bytes || bytes.length !== 5 + 64) return null

const payload = bytes.subarray(0, 5)
const signature = bytes.subarray(5)

const valid = await crypto.subtle.verify(
  { name: "ECDSA", hash: "SHA-256" },
  publicKey,
  signature,
  payload,
)
if (!valid) return null

// payload = version(1) || serial(uint32 BE)
return new DataView(payload.buffer, 1, 4).getUint32(0, false)
Enter fullscreen mode Exit fullscreen mode

That's it. crypto.subtle.verify is available in every modern browser over https (and even file://). Enter the key with the network switched off and it still validates. The serial it returns is remembered in localStorage so the unlock UI doesn't re-prompt.

Why the server re-verifies the same signature

An offline-verifiable key has a structural weakness: it's a client-side check. A user can patch the bundle, delete the check, or just read the source. So the browser check is the fast path, not the gate.

The gate is the Worker. When you redeem a key, the server verifies the same signature with the same public key, and only then mints access:

// functions/api/unlock.ts
const serial = await verifyLicenseSerial(licenseKey)
if (serial === null)
  return fail(401, "That key isn't valid. Check it and try again.")

// Redemption ledger, keyed by serial (KV)
const ledgerKey = `lic:${serial}`
const used = Number((await env.PACK_KV.get(ledgerKey)) ?? "0") || 0
if (used >= MAX_REDEMPTIONS) return fail(403, "Key used too many times.")
await env.PACK_KV.put(ledgerKey, String(used + 1))
Enter fullscreen mode Exit fullscreen mode

Two things worth saying about that ledger:

  • MAX_REDEMPTIONS = 5, not 1. A buyer legitimately unlocks more than once (second device, a retry, a fresh run after the 24-hour document window closes). But each redemption spends a full paid-tier generation, so it can't be unbounded — one $19 key posted publicly would bill me forever. 5 is the ceiling where neither side is harmed.
  • KV has no compare-and-swap. Two simultaneous unlocks can read the same count and both write count+1, losing one tick. That's the right way to lose this race: a buyer is never wrongly locked out, and the ceiling still holds within one extra redemption.

The public key being in the client is not a weakness to hide — it's the design. Ship the public half, keep the private half in .secrets/, verify in two places, and a patched bundle still can't forge a key.

The same constraint also moved parsing into the browser

The "privacy by construction" promise didn't stop at license keys. The resume file itself never leaves the browser until it's been reduced to plain text — PDF via pdfjs-dist (with CJK CMaps for Chinese resumes), DOCX via mammoth, both dynamically imported so the landing page stays lean:

if (ext === "pdf") {
  const { getDocument, GlobalWorkerOptions } = await import("pdfjs-dist")
  const workerUrl = (await import("pdfjs-dist/build/pdf.worker.min.mjs?url")).default
  GlobalWorkerOptions.workerSrc = workerUrl
  const buf = await file.arrayBuffer()
  const pdf = await getDocument({
    data: buf,
    cMapUrl: "/cmaps/",   // CJK CID fonts need pdf.js's glyph CMaps
    cMapPacked: true,
  }).promise
  // ...re-cluster pdf.js text items into rows, split into lines
}
Enter fullscreen mode Exit fullscreen mode

Only the extracted plain text is ever sent to the API — one request, held ≤24h, deleted. Which is exactly the claim that makes "no accounts" honest: there's nowhere to store a profile, so there's nothing to leak.

What I'd do differently

  • crypto.subtle is unavailable on insecure origins. The whole scheme silently dies over plain HTTP. Ship HTTPS or ship nothing.
  • The offline key invites reverse-engineering. The public key in the bundle is public, and the check can be patched out. The server-side re-verification closes the forgery hole; it does not stop a determined freeloader from cracking their own copy. For a $19 one-time product, that's an acceptable line — the redemption cap is what actually keeps a shared key from becoming an unlimited leak.
  • Sign more than 5 bytes. Version + serial is minimal by design, but if you want the key to encode the buyer's email or a product tier, ECDSA signs whatever you put in front of it — the payload size just grows.

The lesson I keep re-learning: product constraints are architecture constraints. "No accounts" didn't mean "smaller backend" — it meant no backend entitlement at all, which is a strictly harder problem. But it's also why the system holds together: every part re-verifies what the part before it trusted, and nothing trusts the client.

Top comments (0)