DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A local app cannot keep a secret from its owner, so stop pretending

Our desktop app has a paid upgrade. Somewhere in the code there has to be a boolean that decides whether the feature is on. Here is the comment sitting above it:

 * Note this is a speed bump, not DRM. A local Electron app cannot keep a secret
 * from its owner, and this file is plain JSON. Anything that must not be forged
 * has to be enforced server-side.
Enter fullscreen mode Exit fullscreen mode

I want to defend writing that down, because the instinct is to do the opposite — encrypt the file, obfuscate the bundle, add a checksum, make it look hard — and every hour spent on that is an hour spent losing an unwinnable game against a user who can open devtools.

The actual threat model

An Electron app is a web app plus a filesystem. The user owns the machine, the process and the bytes. They can read your source, set a breakpoint, patch the asar, or just edit the JSON. There is no key you can hide from them, because any key your code can reach at runtime, they can reach at runtime.

So the honest question is not "how do we stop this" but "what happens if they do it".

For us: they turn on a locally-executed feature — a browser on their own machine replaying a form-filling recipe they recorded themselves — that costs us nothing per use. Someone determined enough to patch a JSON file to skip a one-time payment was, realistically, never going to pay it.

What we do care about is that the server never honours a forged claim. Anything that sends email on our infrastructure, anything that would let one purchase serve many people: that is checked server-side, against the database, every time. POST /api/validate is the authority. The local file is a cache of its answer, and it is labelled as one.

Caching a "yes" you never have to re-check

The interesting design fell out of the purchase being one-time:

 * Because the upgrade is a one-time purchase it never expires, so there is no
 * period end to enforce and no reason to stop trusting a cached "yes" — the
 * only thing that takes it away is deactivating the licence, which clears this
 * cache locally anyway.
Enter fullscreen mode Exit fullscreen mode

Subscription entitlements are genuinely hard: the grant has an end date, so you need a grace period, renewal handling, and a policy for "the card failed but we have not given up yet". Every one of those is a place to wrongly lock out a paying customer.

A one-time purchase has none of it. Once true, always true. That makes the offline story trivial:

/** Re-check this often while online. */
const REFRESH_AFTER_MS = 6 * 60 * 60 * 1000; // 6 hours
Enter fullscreen mode Exit fullscreen mode

Six hours is a freshness target, not an expiry. A stale cache is still used. A failed network call is not a downgrade. The header says why:

 * We cache the answer on disk so a flaky connection or an offline laptop does
 * not switch the feature off mid-search.
Enter fullscreen mode Exit fullscreen mode

The direction the failure points is the entire design. A customer on a train losing a feature they bought is a support ticket and a refund request. A freeloader keeping a local feature for an extra day is nothing. So the network path fails open, and that is a decision the pricing model earned us — worth noticing that a billing choice simplified an offline-sync problem out of existence.

Two layers of cache, deliberately

let _memory: CachedEntitlement | null = null;

function readCache(): CachedEntitlement | null {
  if (_memory) return _memory;
  try {
    if (!fs.existsSync(ENTITLEMENT_PATH)) return null;
    const parsed = JSON.parse(fs.readFileSync(ENTITLEMENT_PATH, 'utf8')) as CachedEntitlement;
    if (!parsed || typeof parsed.fetchedAt !== 'string') return null;
    // ...
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Memory in front of disk, because the entitlement is consulted inside the poll loop and a synchronous file read every 30 seconds is pointless work. And note typeof parsed.fetchedAt !== 'string' — the cast to CachedEntitlement is a lie the compiler cannot check, since the bytes came off a disk the app does not control. One runtime check at the boundary turns "trust me" into something true.

Every path returns null rather than throwing. A corrupt cache means "unknown", which means ask the server. It must never mean "crash".

The rule

Split the question in two. What does forging this cost us? If the answer is "nothing per use", a JSON file is the right amount of engineering, and you should say so in a comment so the next person does not spend a week encrypting it. What must never be forged? That part lives on a server you control, checked every time, no cache.

The mistake is not choosing weak local enforcement. It is building weak local enforcement that looks strong, and then trusting it somewhere it matters.

Have a look

The upgrade this guards, and what it costs, is at notifio.app/pricing — one payment, no subscription, which is the billing decision that made all of the above easy.

The app itself is at notifio.app; the entitlement file is plain JSON in the app's data directory, exactly as advertised, and you are welcome to go and look at it.

Top comments (0)