Add license keys, trials, and feature gating to your Manifest V3 extension — validated in the background service worker, cached in chrome.storage.local, and enforced everywhere from your popup to your content scripts.
Thirty seconds, three steps. By the end you'll have a real license check running inside a service worker.
Step 1 — Make sure your manifest.json allows talking to the API:
{
"permissions": ["storage", "alarms"],
"host_permissions": ["https://api.unifiedlicensing.com/*"]
}Step 2 — Drop this into background.js:
// background.js
const API = 'https://api.unifiedlicensing.com/api/v1/validate-license';
chrome.runtime.onInstalled.addListener(async () => {
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: 'ul_live_YOUR_VENDOR_API_KEY',
product_key: 'prod_YOUR_PRODUCT_KEY',
license_key: 'XXXX-XXXX-XXXX-XXXX',
machine_id: crypto.randomUUID(),
platform: 'extension'
})
});
const data = await res.json();
console.log('License valid:', data.valid);
});Step 3 — Go to chrome://extensions, enable Developer mode, click Load unpacked, pick your extension folder, then click the service worker link on your extension card to open DevTools and see the result.
The quick start hardcodes a license key just to prove the pipe works. Real extensions collect the key from the user in a popup — that's Section 5. The rest of this guide turns this snippet into something shippable.
Before writing code, grab three things from your vendor dashboard:
ul_live_...)prod_...)By the end of this guide your extension will look like this:
my-extension/
├── manifest.json
├── background.js ← validation lives here
├── popup.html ← license status UI
├── popup.js
├── content.js ← optional page integration
└── unifiedlicensing.js ← the SDK, copied inDownload UnifiedLicensing.JavaScript.v4.0-Full.js from your dashboard and copy it into the extension folder as unifiedlicensing.js:
# macOS / Linux cp ~/Downloads/UnifiedLicensing.JavaScript.v4.0-Full.js ./unifiedlicensing.js # Windows copy "%USERPROFILE%\Downloads\UnifiedLicensing.JavaScript.v4.0-Full.js" unifiedlicensing.js
Manifest V3 gives you two very different JavaScript environments, and they have different superpowers:
| Environment | Has window / localStorage? | Use |
|---|---|---|
| Popup, options page, tab pages | Yes | Full SDK works as-is: new UnifiedLicenseManager({...}) |
| Background service worker | No | No DOM, no localStorage. Use the lightweight fetch() client shown in Section 4 and cache verdicts in chrome.storage.local. |
Manifest V3 forbids loading scripts over the network — <script src="https://cdn..."> will be blocked by the Content Security Policy. The SDK must live inside your extension package, which is exactly why we copied the file in above. Bonus: bundling means your licensing logic keeps working even if a CDN has a bad day.
Here's a complete Manifest V3 manifest wired up for licensing:
{
"manifest_version": 3,
"name": "My Awesome Extension",
"version": "1.0.0",
"description": "An extension licensed with UnifiedLicensing.",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": ["storage", "alarms"],
"host_permissions": [
"https://api.unifiedlicensing.com/*"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "My Awesome Extension"
},
"content_scripts": [
{
"matches": ["https://app.example.com/*"],
"js": ["content.js"]
}
]
}"storage" — lets every part of the extension read/write the cached license verdict in chrome.storage.local."alarms" — powers periodic re-validation with chrome.alarms, even after the service worker has gone to sleep."host_permissions" — grants your extension's fetches permission to call api.unifiedlicensing.com without CORS headaches. The pattern must match the API origin exactly."background" — registers the service worker where validation actually happens."content_scripts" — only needed if you want to gate features on web pages (see Section 6). If your content script should run everywhere, use "matches": ["<all_urls>"].After editing manifest.json, hit the circular reload arrow on your extension card at chrome://extensions. Permission changes sometimes require the extension to be fully removed and re-loaded.
The service worker is the brain of your licensing system. It's the one place that:
host_permissions),Keep all API calls here, and let everything else ask nicely via messages (that's Section 6).
The API wants a machine_id so seats can be counted per install. Generate one UUID per installation and persist it:
// background.js
const API_BASE = 'https://api.unifiedlicensing.com/api/v1';
const API_KEY = 'ul_live_YOUR_VENDOR_API_KEY';
const PRODUCT_KEY = 'prod_YOUR_PRODUCT_KEY';
async function ensureMachineId() {
const { ul_machine_id } = await chrome.storage.local.get('ul_machine_id');
if (!ul_machine_id) {
await chrome.storage.local.set({ ul_machine_id: crypto.randomUUID() });
}
}
async function getMachineId() {
const { ul_machine_id } = await chrome.storage.local.get('ul_machine_id');
return ul_machine_id;
}One function, one HTTP call, honest error handling:
async function validateLicense(licenseKey) {
try {
const res = await fetch(`${API_BASE}/validate-license`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: API_KEY,
product_key: PRODUCT_KEY,
license_key: licenseKey,
machine_id: await getMachineId(),
platform: 'extension'
})
});
if (!res.ok) {
return { valid: false, error: `HTTP ${res.status}` };
}
return await res.json(); // { valid, tier, expires_at, features, ... }
} catch (err) {
// Network down, DNS sad, laptop on a plane...
return { valid: false, error: err.message, offline: true };
}
}Every successful check writes a snapshot into chrome.storage.local. That snapshot is what the rest of the extension reads — instantly, with zero network:
async function validateAndStore(licenseKey) {
const result = await validateLicense(licenseKey);
await chrome.storage.local.set({
ul_license_key: licenseKey,
ul_status: {
valid: !!result.valid,
tier: result.tier || null,
expires_at: result.expires_at || null,
features: result.features || [],
trial: result.tier === 'trial',
last_validated: Date.now()
}
});
return result;
}Two lifecycle hooks do the heavy lifting: chrome.runtime.onInstalled fires on install and update, and chrome.alarms wakes the (long-since-terminated) service worker up every 12 hours for a fresh check:
const ALARM_NAME = 'ul-revalidate';
const ALARM_PERIOD_MIN = 12 * 60; // every 12 hours
chrome.runtime.onInstalled.addListener(async () => {
await ensureMachineId();
// Extension update? Re-validate whatever key we already have.
const { ul_license_key } = await chrome.storage.local.get('ul_license_key');
if (ul_license_key) await validateAndStore(ul_license_key);
// Schedule periodic re-validation.
chrome.alarms.create(ALARM_NAME, { periodInMinutes: ALARM_PERIOD_MIN });
});
// Also re-validate when the browser starts a new session.
chrome.runtime.onStartup.addListener(async () => {
const { ul_license_key } = await chrome.storage.local.get('ul_license_key');
if (ul_license_key) await validateAndStore(ul_license_key);
});
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== ALARM_NAME) return;
const { ul_license_key } = await chrome.storage.local.get('ul_license_key');
if (ul_license_key) await validateAndStore(ul_license_key);
});MV3 service workers are ephemeral — Chrome kills them after roughly 30 seconds of idle time, which murders any setInterval you were fond of. chrome.alarms is the sanctioned way to schedule future work: Chrome persists the alarm and spins the worker back up when it fires. Never keep licensing state in global variables; always read it back from chrome.storage.local.
The popup has two jobs: show status instantly (read the cached verdict from chrome.storage.local — no network on open) and collect a license key, handing activation off to the background script.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>/* your styles — full version in Section 10 */</style>
</head>
<body>
<h1>My Awesome Extension</h1>
<!-- status badge, filled in by popup.js -->
<span id="status" class="badge">Checking…</span>
<div id="trial-info" hidden></div>
<!-- shown when there's no valid license -->
<div id="activate-form" hidden>
<input id="license-input" placeholder="XXXX-XXXX-XXXX-XXXX"
autocomplete="off">
<button id="activate-btn">Activate license</button>
<button id="trial-btn">Start free trial</button>
</div>
<!-- shown when licensed -->
<button id="deactivate-btn" hidden>Deactivate</button>
<p id="message"></p>
<script src="popup.js"></script>
</body>
</html>No spinners needed — the background already did the network work:
// popup.js
const $ = (id) => document.getElementById(id);
document.addEventListener('DOMContentLoaded', render);
async function render() {
const { ul_status = null, ul_license_key = null } =
await chrome.storage.local.get(['ul_status', 'ul_license_key']);
const badge = $('status');
if (!ul_license_key) {
badge.textContent = 'Not activated';
badge.className = 'badge inactive';
$('activate-form').hidden = false;
return;
}
$('activate-form').hidden = true;
$('deactivate-btn').hidden = false;
if (ul_status?.valid) {
badge.textContent = ul_status.trial
? 'Trial active'
: `Licensed (${ul_status.tier})`;
badge.className = 'badge active';
} else {
badge.textContent = 'License invalid';
badge.className = 'badge inactive';
$('activate-form').hidden = false;
}
}The popup never calls the API directly. It sends a message, the service worker validates and stores, then replies:
$('activate-btn').addEventListener('click', async () => {
const key = $('license-input').value.trim();
if (!key) return;
$('activate-btn').disabled = true;
$('message').textContent = 'Validating…';
const res = await chrome.runtime.sendMessage({
type: 'UL_ACTIVATE',
licenseKey: key
});
$('activate-btn').disabled = false;
$('message').textContent = res.valid ? '✓ Activated!' : `✗ ${res.error}`;
render();
});And the matching handler in background.js:
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type === 'UL_ACTIVATE') {
validateAndStore(msg.licenseKey).then(sendResponse);
return true; // keep the channel open for the async reply
}
});If your onMessage listener responds asynchronously, it must return true. That keeps the message channel open so sendResponse still works after your await. Forget it and the popup gets undefined back — a classic MV3 rite of passage.
A fully styled, production-ready popup.html + popup.js pair is included in the complete extension below.
Content scripts run inside web pages, which changes the rules:
host_permissions. A fetch() from a content script is treated as coming from the page itself, so normal page CORS applies — your API call will likely be blocked.API_KEY out of content scripts entirely.The fix is simple: content scripts ask the background for license status via message passing.
┌───────────────┐ chrome.runtime.sendMessage ┌───────────────────┐
├ content.js ┞ ──────────────────────────▶ ├ background.js (SW) ├
├ (inside a tab) ┞ ├ getStatus() ├
├ ┞ ███████████████████████──── ├ ├
└───────────────┘ sendResponse(status) └────────────────────┘
│ reads
┌────────────────────┘
├ chrome.storage.local ├
└────────────────────┘// content.js
chrome.runtime.sendMessage({ type: 'UL_GET_STATUS' }, (status) => {
if (chrome.runtime.lastError) {
// Extension was updated/reloaded — this script is orphaned.
console.warn('[License]', chrome.runtime.lastError.message);
return;
}
if (status?.licensed) {
enableProFeatures(status);
} else {
showUpgradeBanner();
}
});
function enableProFeatures(status) {
if (status.features.includes('export-pdf')) {
document.body.classList.add('pro-export-enabled');
}
}
function showUpgradeBanner() {
const banner = document.createElement('div');
banner.textContent = 'Unlock Pro features — activate your license.';
banner.style.cssText =
'position:fixed;top:0;left:0;right:0;z-index:999999;' +
'padding:8px;text-align:center;background:#C7A34C;color:#081220;' +
'font:600 14px system-ui;';
document.body.appendChild(banner);
}Add this to the same onMessage listener from Section 5. The getStatus() helper reads only from storage — fast enough to answer while the page loads:
const GRACE_MS = 72 * 60 * 60 * 1000; // 3-day offline grace window
async function getStatus() {
const { ul_status, ul_license_key } =
await chrome.storage.local.get(['ul_status', 'ul_license_key']);
if (!ul_license_key || !ul_status) {
return { licensed: false, reason: 'not_activated' };
}
if (!ul_status.valid) {
return { licensed: false, reason: 'invalid' };
}
if (ul_status.expires_at && new Date(ul_status.expires_at) < new Date()) {
return { licensed: false, reason: 'expired' };
}
if (Date.now() - ul_status.last_validated > GRACE_MS) {
return { licensed: false, reason: 'grace_period_exceeded' };
}
return {
licensed: true,
tier: ul_status.tier,
trial: ul_status.trial,
features: ul_status.features
};
}
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type === 'UL_GET_STATUS') {
getStatus().then(sendResponse);
return true;
}
// ... UL_ACTIVATE handler from Section 5 ...
});When you ship an update, old tabs still run the old content script, but its background worker is gone. The chrome.runtime.lastError check above handles that gracefully. If you want those tabs to recover, listen for window.addEventListener('load', ...) retries or ask users to refresh — Chrome does not auto-inject new content scripts into existing tabs.
Treat a trial as just another license — one with tier: "trial" and an expires_at date. The server tracks the expiry; your extension renders the countdown.
Create a trial key in your dashboard (or embed a public one for self-serve trials), then validate it exactly like any other key:
// background.js
const TRIAL_KEY = 'UL-TRIAL-8F3K-2M9Q'; // issued from your dashboard
async function startTrial() {
const result = await validateAndStore(TRIAL_KEY);
return result; // { valid: true, tier: 'trial', expires_at: '...' }
}Wire it to the popup's trial button:
// popup.js
$('trial-btn').addEventListener('click', async () => {
$('message').textContent = 'Starting trial…';
const res = await chrome.runtime.sendMessage({ type: 'UL_START_TRIAL' });
$('message').textContent = res.valid ? '✓ Trial started!' : `✗ ${res.error}`;
render();
});// background.js — add to your onMessage listener
if (msg.type === 'UL_START_TRIAL') {
startTrial().then(sendResponse);
return true;
}Because expires_at is already in storage, the countdown is pure math:
// popup.js — inside render(), when status.trial is true
if (ul_status.expires_at) {
const msLeft = new Date(ul_status.expires_at) - Date.now();
if (msLeft > 0) {
const days = Math.floor(msLeft / 86400000);
const hours = Math.floor((msLeft % 86400000) / 3600000);
$('trial-info').hidden = false;
$('trial-info').textContent =
`${days}d ${hours}h left in your trial`;
} else {
badge.textContent = 'Trial ended';
badge.className = 'badge inactive';
$('activate-form').hidden = false; // time to buy :)
}
}Uninstalling wipes chrome.storage.local, including ul_machine_id — so a reinstall looks like a brand-new machine. For casual products that's fine. If trials are expensive to you, gate trial starts behind an email signup on your server, where one identity = one trial regardless of reinstalls.
chrome.storage.local is your offline cache. Every successful validation writes a verdict plus a timestamp (last_validated). When the network disappears, you trust that cache for a grace period instead of hard-locking your users mid-flight.
| Situation | What happens | User sees |
|---|---|---|
| Online, license valid | Fresh verdict stored with new timestamp | Full access |
| Offline, cache within grace window | Cached verdict used, flagged cached: true |
Full access + "offline mode" hint |
| Offline, cache older than grace window | Status treated as unlicensed until next successful check | Features locked |
| Online, server says invalid/expired | Negative verdict stored immediately | Features locked |
async function validateWithFallback(licenseKey) {
const online = await validateLicense(licenseKey);
if (!online.offline) return online; // got a real answer from the server
// Network failed — decide from the cache.
const { ul_status } = await chrome.storage.local.get('ul_status');
const cacheFresh = ul_status?.valid &&
Date.now() - ul_status.last_validated < GRACE_MS;
if (cacheFresh) {
return { ...ul_status, cached: true };
}
return { valid: false, error: 'Offline and no valid cached license' };
}Use validateWithFallback() anywhere you'd otherwise call validateLicense() directly — the periodic alarm in Section 4 is a great candidate, since it's exactly the check that runs when the user might be offline.
7 * 24 * 60 * 60 * 1000).chrome.storage.local persists across browser restarts, crashes, and updates. It's only cleared when the user fully removes the extension (or clears extension data). That makes it ideal for license caches — and remember, clearing it also regenerates ul_machine_id.
Don't just check the license once on install — gate every premium feature on every use. A pirate who deletes your check has to rewrite every gate call, not just one.
// background.js — reusable gate function
async function isPremiumEnabled() {
const { ul_valid, ul_tier } = await chrome.storage.local.get(['ul_valid', 'ul_tier']);
return ul_valid && ul_tier !== 'free';
}
// Use it before any premium action
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type === 'UL_UNLOCK_FEATURE') {
isPremiumEnabled().then(enabled => {
sendResponse({ allowed: enabled });
});
return true; // async response
}
});// content.js — check before injecting premium UI
async function initPremiumFeatures() {
const response = await chrome.runtime.sendMessage({ type: 'UL_UNLOCK_FEATURE' });
if (!response.allowed) {
document.body.classList.add('ul-free-only');
return;
}
injectPremiumUI();
}
initPremiumFeatures();A pirate who edits background.js to always return { success: true } still has to find and remove every isPremiumEnabled() call across your content scripts, popup, and background logic. More gates = more work = most pirates give up.
Chrome extensions are code-signed by the Web Store, which makes tampering harder than WordPress. But unpacked/sideloaded extensions, DevTools, and chrome.storage.local are all accessible. Here's how to raise the bar.
Don't use obvious key names like ul_license_key. A pirate searching DevTools can spot them instantly:
// BAD — obvious, searchable
await chrome.storage.local.get('ul_license_key');
// BETTER — opaque key names
const SK = {
k: 'u4k_lic_v2',
v: 'u4k_val_ts',
s: 'u4k_status',
t: 'u4k_tier',
m: 'u4k_mid',
};
await chrome.storage.local.get(SK.k);Store a checksum of your license data. If someone edits the storage directly, the checksum won't match:
// After every successful validation
const checksum = await computeChecksum(data);
await chrome.storage.local.set({ ul_data: data, ul_cs: checksum });
async function computeChecksum(d) {
const enc = new TextEncoder();
const hash = await crypto.subtle.digest('SHA-256', enc.encode(JSON.stringify(d)));
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
}
// Before using license data, verify integrity
async function getVerified() {
const { ul_data, ul_cs } = await chrome.storage.local.get(['ul_data', 'ul_cs']);
if (!ul_data) return null;
const expected = await computeChecksum(ul_data);
if (ul_cs !== expected) {
await chrome.storage.local.clear(); // tampered — force re-validation
return null;
}
return ul_data;
}Never put premium logic in content scripts alone — it's visible in DevTools Sources tab. Keep gating in the background and only send the result:
// content.js — thin client, no premium logic
async function checkAccess() {
const response = await chrome.runtime.sendMessage({ type: 'UL_UNLOCK_FEATURE' });
return response.allowed;
}
// Don't do this:
// const isPremium = licenseData.tier === 'pro'; // pirate can see and edit| Layer | What it stops | Effort to bypass |
|---|---|---|
| Periodic re-validation | Stale cached licenses | Disable alarms |
| Feature gating function | Simple deletion of checks | Rewrite all gate calls |
| Obfuscated storage keys | Quick DevTools search | Find the real keys |
| Integrity checksum | Direct storage edits | Recalculate hash |
| Background-only gating | Content script edits | Modify service worker |
Chrome extensions are already harder to crack than WordPress because the Web Store signs the code. Stack these layers and most pirates won't bother.
Common issues and how to fix them:
You shipped an update while a tab was running the old content script. The chrome.runtime.lastError check in Section 6 handles this. Ask the user to refresh the tab.
MV3 service workers terminate after ~30 seconds of inactivity. Use chrome.alarms (not setInterval) for periodic work. Always read state from chrome.storage.local, never from global variables.
Content scripts don't get host_permissions. Route all API calls through the background script via chrome.runtime.sendMessage().
This is by design. chrome.storage.local is only cleared on full extension removal. But reinstalling wipes ul_machine_id, so it looks like a new device. If that's a problem, gate trial starts behind email on your server.
Check that ul_valid and ul_tier are actually in chrome.storage.local. Open DevTools on the service worker and inspect storage. If the keys are missing, the validation response may not have been stored correctly.
Open the service worker DevTools, check the Console for errors, and reach out via the support channels on the guides index.