Add Lifecheck to your site.
Lifecheck is a drop-in human check. The widget runs in a sandboxed frame served from Swiftaw, hands the browser a one-time token on success, and you confirm that token on your server before trusting the request. Two lines to install, one call to verify.
Quickstart
The whole flow, top to bottom:
<!-- 1 · load Lifecheck once (in your <head> or before </body>) -->
<script src="https://swiftaw.com/lifecheck/lifecheck.js" async defer></script>
<!-- 2 · put the widget inside the form you want to protect -->
<form method="POST" action="/signup">
<input name="email" type="email">
<div class="lifecheck" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Create account</button>
</form>
Lifecheck auto-renders the .lifecheck element, sizes its own frame, and injects a hidden <input name="lifecheck-token"> so the token rides along with a normal form POST. Then verify it on your server. That's the entire integration.
Site & secret keys
Lifecheck uses a key pair, one public and one private:
| Key | Where it lives | What it does |
|---|---|---|
Site key | In your HTML, on the widget | Public. Renders the widget and scopes it to your registered domain(s). |
Secret key | On your server only | Private. Signs the call that verifies a token. Never ship it to the browser. |
Register your domain with Swiftaw to receive a pair. Keys look like lc_site_xxx and lc_secret_xxx.
1 Add the widget
Load the script once per page, then place a container. You can configure it entirely with data- attributes:
<div class="lifecheck"
data-sitekey="YOUR_SITE_KEY"
data-callback="onLifecheckPass"
data-expired-callback="onLifecheckExpired"></div>
See every attribute in the data-attribute reference. Prefer to render manually, say inside a modal that opens later? Skip the auto-render and call the API yourself:
<div id="check"></div>
<script>
Lifecheck.render("check", {
sitekey: "YOUR_SITE_KEY",
callback: function(token){ console.log("passed:", token); }
});
</script>
2 Read the token
On a successful check, Lifecheck gives you the token three ways. Use whichever fits:
- Hidden field. A
<input name="lifecheck-token">is added inside the container, so a plain form POST already carries it. - Callback. Your
data-callbackfunction is invoked ascallback(token, widget). - DOM event. The container emits a bubbling
lifecheck:verifiedevent withdetail.token.
Or pull it on demand:
const token = Lifecheck.getResponse(); // "" until verified
// e.g. gate an AJAX submit
form.addEventListener("submit", async (e) => {
const t = Lifecheck.getResponse();
if (!t) { e.preventDefault(); alert("Please complete the check."); }
});
Lifecheck.reset() to hand the user a fresh challenge (e.g. after a failed submit).3 Verify on your server
The client token proves nothing on its own. Always confirm it from your backend (never browser JS, or you'll leak your secret and hit CORS). Verification is a single call to Lifecheck's function endpoint with your secret key and the token:
curl -X POST \
"https://mwszvynzzugbowdngzab.supabase.co/rest/v1/rpc/lifecheck_verify_token" \
-H "apikey: sb_publishable_dqsqX2klo1j4xSyEFA7O1w_UjM8lEGf" \
-H "Content-Type: application/json" \
-d '{ "p_secret": "YOUR_SECRET_KEY", "p_token": "THE_TOKEN_FROM_THE_BROWSER" }'
// server-side only - keep YOUR_SECRET_KEY out of the browser
const res = await fetch(
"https://mwszvynzzugbowdngzab.supabase.co/rest/v1/rpc/lifecheck_verify_token", {
method: "POST",
headers: {
"apikey": "sb_publishable_dqsqX2klo1j4xSyEFA7O1w_UjM8lEGf",
"Content-Type": "application/json"
},
body: JSON.stringify({
p_secret: process.env.LIFECHECK_SECRET,
p_token: req.body["lifecheck-token"]
})
});
const data = await res.json();
if (!data.success) return res.status(400).send("Failed Lifecheck");
$r = file_get_contents(
"https://mwszvynzzugbowdngzab.supabase.co/rest/v1/rpc/lifecheck_verify_token", false,
stream_context_create(["http" => [
"method" => "POST",
"header" => "apikey: sb_publishable_dqsqX2klo1j4xSyEFA7O1w_UjM8lEGf\r\nContent-Type: application/json",
"content" => json_encode([
"p_secret" => $SECRET,
"p_token" => $_POST["lifecheck-token"]
])
]]));
$ok = json_decode($r)->success;
Response
{
"success": true,
"score": 0.85, // 0..1, real as of v1.3
"passed": "signals", // "signals" | "challenge" | "signals-invisible"
"mode": "checkbox", // "checkbox" | "invisible"
"challenge_ts": "2026-07-31T18:04:11Z",
"hostname": "yoursite.com",
"v": "1.3",
"error-codes": []
}
success === true. p_secret is your private secret and must stay server-side. Tokens are single-use and expire ~2 minutes after issue.apikey header is not your Lifecheck key. It is Lifecheck's public API key - the fixed value shown above, identical for every customer, and safe to ship. Sending your own lc_site_… or lc_secret_… key there gets you 401 Invalid API key before the token is ever read. Your keys go in the JSON body: p_secret only.No backend? Verify in the browser safely
A direct browser call to the verify RPC fails CORS on purpose - it stops you shipping your secret to the client. For a fully static site, deploy the lifecheck-verify Edge Function (in supabase/functions/): the browser sends only the public site key + token, and the function looks up the secret server-side and returns a CORS-enabled verdict. Your secret never leaves Supabase. Try it live on the token verifier.
// deploy once: supabase functions deploy lifecheck-verify --no-verify-jwt
const r = await fetch(
"https://mwszvynzzugbowdngzab.supabase.co/functions/v1/lifecheck-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sitekey: "lc_site_...", token: Lifecheck.getResponse() })
});
const verdict = await r.json(); // { success:true, passed:"challenge", ... }
Data-attribute reference
| Attribute | Description | |
|---|---|---|
class="lifecheck" | required | Marks the element for auto-render. (Or data-lifecheck.) |
data-sitekey | required | Your public site key. |
data-callback | optional | Name of a global function called as fn(token, widget) on pass. Dotted paths like app.onPass work. |
data-expired-callback | optional | Global function called when a token expires. |
data-error-callback | optional | Global function called as fn({code, message}, widget) when the widget can't verify at all - sitekey-rejected (unknown key, or this domain isn't allow-listed), issue-request-failed (Lifecheck unreachable) or no-sitekey. Use it to fall back; the pass callback will never fire. |
data-response-field | optional | Renames the hidden input (default lifecheck-token). |
data-size="invisible" | optional | v1.3 Renders nothing. Call Lifecheck.execute() when you need the check; it passes silently or unfolds a challenge in place. See invisible mode. |
JavaScript API
Everything hangs off the global Lifecheck object.
| Method | Returns | Description |
|---|---|---|
Lifecheck.render(el, opts?) | id | Renders a widget into el (element or id). opts: sitekey, callback, expired-callback, error-callback. Returns a numeric widget id. |
Lifecheck.getResponse(ref?) | string | Current token, or "" if not yet verified. ref = id, element, or container id; defaults to the first widget. |
Lifecheck.reset(ref?) | void | Clears the token and loads a fresh challenge. |
Lifecheck.execute(ref?) | widget | v1.3 Runs an invisible-mode check now. Forwards the page's cursor trail to the frame, which either mints a token (your pass callback fires) or unfolds a challenge. |
Lifecheck.version | string | Loader version, e.g. "1.3". |
Verify endpoint
POST /rest/v1/rpc/lifecheck_verify_token (JSON body) request parameters:
| Param | Description | |
|---|---|---|
p_secret | required | Your secret key (server-side only). |
p_token | required | The token returned by the widget in the browser. |
Send Lifecheck's public API key (sb_publishable_dqsqX2klo1j4xSyEFA7O1w_UjM8lEGf - the same for everyone, not one of your own keys) in the apikey header. The endpoint accepts cross-origin requests, but call it from your backend so p_secret never reaches a browser.
Response fields:
| Field | Type | Meaning |
|---|---|---|
success | boolean | Whether the token is valid and unused. Gate on this. |
score | number | 0.0-1.0 confidence the visitor is human, from the nine signals the widget read. v1.3 - before this it was always 0.9, and tokens minted by an older widget still answer 0.9. Gate on success; use score for softer calls like holding a submission for review. |
passed | string | "signals" (passed on behaviour), "challenge" (solved a task) or "signals-invisible". |
mode | string | v1.3 "checkbox" or "invisible". |
challenge_ts | string | ISO-8601 timestamp of the check. |
hostname | string | Domain the check was solved on. |
v | string | Lifecheck version ("1.3"). |
error-codes | array | Present when success is false. See below. |
Error codes
| Code | Meaning |
|---|---|
missing-input-secret | The p_secret parameter was not sent. |
invalid-input-secret | The secret key is unknown (or its key was deleted). |
missing-input-token | The p_token parameter was not sent. |
invalid-input-token | No such token for this key. |
timeout-or-duplicate | The token has expired or was already verified once. Since v1.3 the widget warns you first: it fires data-expired-callback and resets itself just before a token goes stale, so a form left open no longer submits one that is already dead. |
invalid-input-sitekey | Browser endpoint only. No key registered under the sitekey you sent. |
invalid-input-token on a LC1.3-preview_… token | Not a bug. Preview tokens come from a reserved _public site key, are minted entirely in the browser, and are meant to be unverifiable - that is what makes them safe to demo with. Use a real lc_site_… key to get a token that verifies. |
Invisible mode v1.3
Add data-size="invisible" and Lifecheck draws nothing at all. Call Lifecheck.execute() at the moment you actually need a human - on submit, usually. Most visitors never see a thing; the ones the check isn't sure about get a challenge that unfolds in place.
<div class="lifecheck"
data-sitekey="YOUR_SITE_KEY"
data-size="invisible"
data-callback="onPass"></div>
<script>
// the token arrives here, exactly as in checkbox mode
function onPass(token){ form.submit(); }
submitBtn.addEventListener('click', function(e){
e.preventDefault();
Lifecheck.execute();
});
</script>
The container gets data-lifecheck-expanded="true" and fires a lifecheck:expand DOM event when a challenge appears, so you can make room for it. Leave the element where the challenge should show up.
Frame events (advanced)
Under the hood the sandboxed widget talks to the loader over postMessage. You normally never touch this (the loader turns it into the callbacks above), but for custom hosts, messages are shaped:
{ source: "swiftaw-lifecheck", v: "1.3", event: "verified", token: "LC1.3_…" }
{ source: "swiftaw-lifecheck", v: "1.3", event: "resize", height: 74 }
{ source: "swiftaw-lifecheck", v: "1.3", event: "ready" }
{ source: "swiftaw-lifecheck", v: "1.3", event: "error", code: "sitekey-rejected", message: "…" }
{ source: "swiftaw-lifecheck", v: "1.3", event: "expand" } // invisible mode wants a challenge
{ source: "swiftaw-lifecheck", v: "1.3", event: "collapse" } // …and is done with it
event.origin against Lifecheck's origin before trusting a message. The bundled loader does this for you.Testing aids v1.3
Two query parameters on the frame make a widget easier to look at while you're wiring it up. Both only affect what you can see - neither makes a check easier to pass.
| Parameter | Does |
|---|---|
?lcdebug=1 | Logs every signal and event the widget records to the console, and keeps them on window.__lcEvents so you can read exactly what Lifecheck saw. |
?lcgame=trace | Pins the challenge to one game instead of picking at random, so you can look at the same one repeatedly. Any of the eleven ids: imagepick, oddone, tally, grid, slider, rotate, sequence, code, trace, catchit, pairs. |
Anti-theft model
People ask how they can integrate Lifecheck without being able to just copy it. Here's the honest answer:
- The detection logic and the five challenges live in
embed.html, served only from swiftaw.com and loaded inside a sandboxed<iframe>. Integrators embed a URL, not a re-hostable copy of the checks. - Tokens are minted server-side. When a visitor passes, the widget asks Swiftaw to issue a token, which only happens if your site key still exists and the host is on its allow-list. A copied widget with no valid key gets nothing back.
- Live revocation. Delete a key and token issuance stops that instant, and the secret-side verify can no longer find the key. Pages already open can't keep verifying - no reload required.
- The token is single-use and useless until your secret key verifies it. A stolen widget shell can't produce tokens that pass your server check.
Changelog
- Eleven signals, one score. The cursor checks stopped being pass/fail questions: more signals joined the original three, and each costs confidence rather than deciding on its own. The odds of being challenged now follow the score rather than a fixed coin flip.
- Half of all sessions play a game. The other half are read from behaviour and only interrupted when the signals don't add up. Split at random per visit, so looking smooth is no longer a way through on its own. Invisible mode is exempt, by design.
- Expiring tokens announce themselves.
data-expired-callbackhas been documented since v1.1 and could never actually fire; it does now, and the widget resets itself so the visitor can simply check again. - Every board is unique. Tiles get their own rotation, scale and mirror, the beacons carry real numbers instead of always 1 to 5, and the traced beam is sometimes an S-bend. No two boards render the same pixels.
- The two pointer-only games offer a way out. Trace and Tag need a mouse, so they are never shown on touch, refuse keyboard activation, and carry a one-press swap to a fully keyboard-playable challenge.
- Three new mini-games. Trace the beam, tag the probe, find the twins - eleven in all.
- Every challenge is keyboard-playable. Tab and Enter to pick, arrows to steer the slider and the dial, real labels on everything. Grab-anywhere dragging, Enter to submit the code, and no more empty-grid submissions.
- Invisible mode.
data-size="invisible"plusLifecheck.execute(). See the section above, including what it trades away. - Friendlier failures. A check that can't run says so in its own voice and offers another crank, instead of dead-ending on an error the visitor can't do anything about.
- Reserved preview keys. A site key ending in
_publicnever reaches the server and mints an unverifiableLC1.3-preview_…token. It's how our own demos run; it is not something to ship on a real form. - Richer interaction insights, so the check - and Swiftaw's AI - keeps getting better.
- New mini-games. Pick the life form, spot the odd one out, and count the life forms - drawn with Twemoji so they look the same everywhere.
- Refreshed widget. The LifeCheck wordmark, a wider card, and smoother motion.
- Interaction insights that help the check keep getting better.
- Hardened verification and behind-the-scenes tuning.
- Per-site API keys, server-issued tokens, and a verify endpoint.
- Self-sizing frame via
resizemessages, so the box never clips. - Hidden-field auto-injection for zero-JS form submits.
lifecheck:verifiedDOM event + dotted-path callbacks.
- Cursor-behaviour detection (distance, jitter, reaction time) with touch fallback.
- Themed challenges + the “I'm not a robot” checkbox widget.
Ready to wire it up? Jump back to the quickstart or see the overview.