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: LIFECHECK_PUBLIC_KEY" \
-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": "LIFECHECK_PUBLIC_KEY",
"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: LIFECHECK_PUBLIC_KEY\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.9,
"passed": "signals", // "signals" | "challenge"
"challenge_ts": "2026-07-31T18:04:11Z",
"hostname": "yoursite.com",
"v": "1.2",
"error-codes": []
}
success === true. apikey is the public Lifecheck key (safe to ship); p_secret is your private secret and must stay server-side. Tokens are single-use and expire ~2 minutes after issue.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-response-field | optional | Renames the hidden input (default lifecheck-token). |
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. 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.version | string | Loader version, e.g. "1.2". |
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 the public Lifecheck key 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. |
passed | string | "signals" (passed on behaviour) or "challenge" (solved a task). |
challenge_ts | string | ISO-8601 timestamp of the check. |
hostname | string | Domain the check was solved on. |
v | string | Lifecheck version ("1.2"). |
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. |
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.2", event: "verified", token: "LC1.2_…" }
{ source: "swiftaw-lifecheck", v: "1.2", event: "resize", height: 74 }
{ source: "swiftaw-lifecheck", v: "1.2", event: "ready" }
event.origin against Lifecheck's origin before trusting a message. The bundled loader does this for you.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
- 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.