Why Licensing Is the Buggiest Part of a Paid Extension
A license check has an unusual property: every failure mode is silent. The network fails quietly, the server answers 200 with a rejection inside the body, the cache goes stale, the UI reads a different helper than the background does. Nothing throws. Nothing logs. The extension just keeps working — for free — or stops working for someone who paid.
Across four extensions sharing one licensing stack, these are the six failure modes that actually shipped and had to be fixed. Each one has the same shape: a plausible local implementation that is wrong only in combination with the rest of the system.
1. Offline-Forever Pro
Symptom: a revoked or expired license keeps working indefinitely.
Cause: validation was wired into validateOnOpen() — a function called when the user opens the popup or the side panel. That is a reasonable-sounding design ("check when the user looks at it") with one fatal property: if the user never opens the UI, no check ever runs. Plenty of users install, activate, and then only use the context menu for months.
Fix: move validation into the service worker and drive it with an alarm, so it runs whether or not any UI exists:
const LICENSE_VALIDATE_ALARM = 'jf_license_validate';
const LICENSE_VALIDATE_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2h
// Created idempotently on install AND on startup —
// a service worker restart must not lose the schedule.
chrome.alarms.create(LICENSE_VALIDATE_ALARM, {
delayInMinutes: 120,
periodInMinutes: 120
});
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === LICENSE_VALIDATE_ALARM) await runLicenseValidation();
});
Two details matter. First, the same throttle key is shared by page-side and worker-side validation, so opening the UI does not trigger a second request. Second, the alarm must be created on both onInstalled and onStartup — alarms survive worker restarts but not always profile-level events.
2. The UI and the Background Disagree
Symptom: the popup shows "Premium" while premium features are already gone. Or the reverse: the user paid and the UI still shows the free tier.
Cause: two different definitions of "licensed". The UI used a permissive cached check that only looked at status === 'active'. The background used a strict check that also required the expiry to be in the future. Between the moment a license expires and the next successful validation (up to two hours later), the two disagreed.
// Too weak — claims Premium for up to VALIDATE_INTERVAL_MS after expiry
const licensed = stored.status === 'active';
// Must mirror the background's definition exactly
function licenseEffectivelyActive(lic) {
if (!lic || lic.status !== 'active') return false;
if (lic.expiresAt && new Date(lic.expiresAt).getTime() <= Date.now()) return false;
return true;
}
The fix is not "make the UI stricter" in isolation — it is to have exactly one definition and call it from both sides. The comment left in license.js says this explicitly, because a future refactor will otherwise reintroduce a local shortcut.
3. The In-Flight Snapshot Race
Symptom: a license the user removed comes back. Or a freshly activated license is silently replaced by an older record.
Cause: validation reads the stored record at the start, awaits a network round trip, then writes back a record built from that stale snapshot. Anything the user did during those 300–800 ms — remove, re-activate, switch accounts — is overwritten.
const markStale = async () => {
// Re-read before writing: the user may have removed or re-activated the
// license while the request was in flight. Writing our snapshot back would
// resurrect a removed license or clobber a freshly activated one. Also
// skip the write entirely when staleSince is already set — an
// unconditional write-back widens that same race for no benefit.
const fresh = await _getStorage(LICENSE_KEY);
if (!fresh || fresh.staleSince) return;
fresh.staleSince = Date.now();
await _setStorage({ [LICENSE_KEY]: fresh });
};
The success path needs the same treatment, plus an identity check — if the key changed mid-flight, the response belongs to a license the user no longer has:
const fresh = await _getStorage(LICENSE_KEY);
if (!fresh || fresh.key !== stored.key) return { ok: false, error: 'no_license' };
// ...apply valid/plugin_allowed onto `fresh`, never onto `stored`
4. The Server Says 200 and Means "No"
Symptom: revoked licenses are labelled plugin_not_allowed, so support tickets say "my license is blocked" instead of "my license expired".
Cause: the API contract returns HTTP 200 with {valid: false, plugin_allowed: false} for every rejection — expiry, revocation, wrong plugin, malformed key. A client that checks plugin_allowed first maps all of them to the "plugin not allowed" branch.
// Test `valid` FIRST. A revoked or expired license comes back as
// HTTP 200 with valid:false AND plugin_allowed:false.
if (!data.valid) {
fresh.status = 'invalid';
} else if (data.plugin_allowed === false) {
fresh.status = 'plugin_not_allowed';
} else {
fresh.status = 'active';
}
The same contract has a second trap: a 200 response whose body is not JSON (a captive portal, a proxy error page, an HTML maintenance page). That is a transport failure, not a revocation — it must go down the "keep the cached license, mark it stale" path, not the "invalidate" path.
5. Dead Code Behind a Missing Export
Symptom: none. That is the point. Throttled validation "exists" in the source and never runs.
Cause: a module-private function was used through the public object:
// license.js — function defined, but omitted from the returned object
async function validateOnOpen() { /* ... */ }
return {
// validateOnOpen, <-- missing
getStoredLicense,
FREE_MAX_CONFIGS
};
// config.js — always false, so the whole 2h throttle below it is dead
if (LicenseManager.validateOnOpen) { ... }
A conditional call site makes this invisible: no TypeError, no lint error, no test failure. The guard that catches it is structural, not behavioural — assert the public surface:
// contract check — runs in CI
for (const fn of ['getStoredLicense', 'hasCachedLicense', 'validateOnOpen', 'isLicenseStale']) {
assert.equal(typeof LicenseManager[fn], 'function', `LicenseManager.${fn} missing`);
}
6. Treating "Offline" as "Revoked"
Symptom: paying users lose premium features on a plane, on hotel Wi-Fi, or behind a corporate proxy that blocks the license host.
Cause: the failure branch of the fetch was written as status = 'invalid'. Any network error therefore behaved like a server rejection.
The correct semantics: a transport failure means unknown, not denied. Keep the cached entitlement, mark it stale, and retry later:
} catch (e) {
await markStale(); // note the timestamp, keep the record
return { ok: true, license: stored, offline: true };
}
// Only an explicit {"valid": false} from the server flips to invalid.
If your product needs a hard offline cap (say 30 days), implement it as an explicit staleSince policy with a visible message — not as an accidental side effect of poor connectivity.
The Non-Technical Failure: Docs That Outrun the Code
One of these extensions advertises "no network requests, ever" while its license code validates on popup open after 24 hours. Another's privacy policy described the storage medium and the device-identity fields inaccurately. Both were documentation bugs, not code bugs — and both are worse than a code bug, because they are the part a reviewer and a paying user actually read.
The practical rule I now apply: the privacy policy is a test fixture. Any claim it makes about network calls, permissions or stored fields must map to a grep-able fact in the manifest and the license module. If the wording and the code disagree, one of them ships as a lie.
Checklist
- Validate from the service worker on an alarm; never rely on the user opening the UI.
- One definition of "licensed", imported by both UI and background.
- Re-read storage after every await, and verify the license key still matches.
- Test
validbeforeplugin_allowed; treat non-JSON 200 as transport failure. - Contract-test the public API surface so a missing export cannot silence a throttle.
- Offline means unknown: keep entitlement, mark stale, retry.
- Make the privacy policy's claims grep-able against manifest and code.
References
- chrome.alarms API — Chrome Docs
- Service Worker Lifecycle — Chrome Docs
- Chrome Web Store Program Policies