OpenAge runs face tracking, liveness checks, and age estimation on-device. Use it as a drop-in age gate with a checkbox-style widget, modal flow, or button binding.
| Browser-side | Server-backed | UI |
|---|---|---|
| On-device face analysis | Optional WASM verification | Widget popup, inline embed, bind |
| No raw camera upload | Signed sessions and tokens | Normal, compact, invisible |
| Serverless soft gates | Hosted or custom backend | Auto, light, dark |
npm install @tn3w/openage<script src="https://cdn.jsdelivr.net/npm/@tn3w/openage/dist/openage.min.js"></script><div class="openage" data-sitekey="ag_live_xxxx" data-callback="onVerified"></div>
<script src="https://cdn.jsdelivr.net/npm/@tn3w/openage/dist/openage.min.js"></script>
<script>
function onVerified(token) {
console.log('verified', token);
}
</script>import OpenAge from '@tn3w/openage';
OpenAge.render('#gate', {
mode: 'serverless',
layout: 'inline',
minAge: 18,
callback: (token) => console.log(token),
errorCallback: (error) => console.error(error),
});OpenAge.render('#gate', {
mode: 'serverless',
layout: 'inline',
minAge: 18,
});layout: 'inline' removes the checkbox shell and renders the verification
panel directly in the container. The first verification step starts
immediately after loading.
OpenAge.bind('#buy-btn', {
sitekey: 'ag_live_xxxx',
callback: (token) => submitForm(token),
});| Mode | Backend | Use case |
|---|---|---|
serverless |
none | client-only soft gates |
sitekey |
OpenAge hosted | production verification |
custom |
your server | self-hosted verification |
serverless keeps everything local and returns a client-signed token.
sitekey and custom use a server session and a WASM VM for stronger checks.
In sitekey/custom mode the challenge algorithm runs as QuickJS bytecode
inside a WebAssembly VM, and the VM seals its output so the server can detect
tampering.
Sealed I/O. All VM inputs and outputs are authenticated:
- Challenge bytecode and encrypted models carry an HMAC-SHA256 tag
(
HMAC(sign_key, header‖nonce‖ciphertext)); the VM refuses to run or decrypt anything whose tag does not verify, so the decrypt export is not an oracle. - The VM result is ChaCha20-encrypted (fresh CSPRNG nonce per response — no nonce reuse) and HMAC-signed. The server verifies the MAC before trusting any field.
Key rotation = the security budget. Keys (decrypt/encrypt/sign) and export
names are baked into each WASM build by build.cjs
and obfuscated (renames, split/masked key material, dead code, decoys). A
static key in a binary is always extractable given enough time, so the
server rebuilds a fresh WASM globally every OPENAGE_ROTATE_INTERVAL (default
15 min) and keeps the previous build valid only for OPENAGE_ROTATE_GRACE
(default 5 min) so in-flight sessions finish. An extracted key is worthless
once its build is pruned: forging requires extraction faster than
interval + grace. Sessions are pinned to the build they started under.
Defence in depth on the server. The client owns the camera and JS, so the VM only signs attacker-influenceable numbers — the server therefore independently re-validates liveness from the motion history, enforces age sanity bounds, requires the VM integrity counter to advance every round, and rejects replayed challenge nonces. Treat the on-device result as one signal, not proof.
- Camera — single permission request. Grant starts immediately; deny, missing, or busy device each show a clear message and a working retry.
- Positioning — waits for one well-lit, centered face.
- Liveness — randomized head-movement tasks (turn, nod, blink, move closer). Must pass before any age decision.
- Age — age is sampled across many frames during the liveness motion (varied poses → less single-frame bias), then a robust decision is made.
decide(samples, minAge) (exported from the main module) turns per-frame age
readings into one outcome:
- Drops low-confidence frames, needs a minimum number of usable samples.
- Uses the median of bias-corrected ages. The model over-reads young faces, so a downward correction is applied below 25 (largest for teens, fading to zero by 25) — this makes sub-18 detection stricter.
- Rejects unstable reads (high spread between frames).
- pass only when the corrected median clears
minAgeby a safety margin. - retry for borderline ages near
minAge(e.g. 18–19) or unreliable reads — the user can try again. - fail when clearly under
minAge.
Thresholds live in src/constants.js
(PASS_MARGIN, BORDERLINE_BAND, MIN_FACE_SCORE, MAX_AGE_SPREAD,
MIN_AGE_SAMPLES). Everything runs on-device; no images leave the browser.
OpenAge.render(container, params);
OpenAge.open(params);
OpenAge.bind(element, params);
OpenAge.reset(widgetId);
OpenAge.remove(widgetId);
OpenAge.getToken(widgetId);
OpenAge.execute(widgetId);
await OpenAge.challenge(params);Runtime errors keep the popup open long enough to explain what happened (camera blocked, missing, or in use), then return to a retryable state. Borderline or unreliable age reads also resolve to retry, so a real adult can simply try again.
| Param | Values |
|---|---|
mode |
serverless, sitekey, custom |
layout |
widget, inline |
theme |
light, dark, auto |
size |
normal, compact, invisible |
minAge |
number, default 18 |
sitekey |
required for hosted mode |
server |
required for custom mode |
- Static demo: https://tn3w.github.io/OpenAge/
- Local server demo:
cd server
pip install -r requirements.txt
python server.pyThe repository also includes demo/, a minimal GitHub Pages build that loads
the jsDelivr bundle for @tn3w/openage in inline embedded serverless mode.
| File | Responsibility |
|---|---|
index.js |
Public API, auto-render, event emitter |
widget.js |
Checkbox/popup/inline shell, positioning, state |
ui.js |
SVG icons, HTML templates, theme detection |
styles.js |
Shadow-DOM CSS (single stylesheet string) |
challenge.js |
Flow orchestration + camera/permission handling |
detect.js |
Face tracking, positioning, liveness, age, decision |
net.js |
Token signing, transport, WASM VM client |
constants.js |
URLs and tunable thresholds |
npm install
npm test
npm run build
npm run devOptional server:
cd server
pip install -r requirements.txt
python server.pypip install black isort
isort . && black .
npx prtfm
clang-format -i server/wasm/src/*.c server/wasm/src/*.h