The idea was to replace CAPTCHA with something better: a camera check that proves a real person is present, resistant to the obvious attacks. Show a photo, get rejected. Play a recorded video, get rejected. Wear a mask, get rejected.

The check works. It reliably separates a live face from a photo, a phone screen and a mask, and I can show you the numbers. What it cannot do is stand on its own as a security control - for reasons that took me a while to accept, and that no further engineering on my part would fix.

What this article covers

  • The threat model correction: why liveness never solved the CAPTCHA problem in the first place.
  • Two signals that genuinely work: controlled illumination and parallax, with the validation numbers.
  • The 28-line attack that beats both, and why it is structural rather than a bug.
  • What a server deployment actually requires, and why "just send the landmarks" fails.
  • How a native app changes the picture - the one architecture that gets both privacy and assurance.

The harness is live: liveness-check.html runs entirely in your browser.

This is a research harness, not a product. Every measurement runs inside the page, which - as the article demonstrates - means it can be trivially forged. Treat it as a measurement instrument.

The first correction: liveness does not solve CAPTCHA

CAPTCHA exists to price out automation at scale. But the dominant attack on CAPTCHA is not automation - it is cheap human labour. Solving farms pay people fractions of a cent per solve.

A liveness check proves a live human is present. A farm worker is a live human, sitting in front of a real camera. So liveness cannot distinguish your legitimate user from a paid solver at all. It raises the cost per solve, but it does so by imposing enormous costs on everyone legitimate:

Liveness is the right tool for identity verification - account recovery, KYC, high-value transactions - where friction is expected and consent is meaningful. It is the wrong tool for "is this a bot". That correction reframed everything that followed.

Two attack classes that get conflated

Almost every discussion of camera liveness muddles these, and the distinction determines which defences are worth building.

Presentation attack Injection attack
What it is Photo, video on a screen, printed or silicone mask held in front of a real camera Frames fed directly into the pipeline - virtual camera, patched browser, hooked getUserMedia
Can optics help? Yes, strongly Barely

Against presentation attacks the physics are on your side. A phone screen replaying video is emissive - it barely reflects light you shine at it. A print has flat, wrong shading response. A mask has wrong subsurface scattering.

Against injection, the attacker never enters the physical world at all. And here is the thing worth internalising: the web platform gives you no way to attest camera provenance. There is no signed-frame API. Injection resistance in a browser is not a hard problem, it is an unsolved one - you can only make it expensive.

What I built

Two signals, chosen because they fail in different ways.

Randomised controlled illumination

The screen steps through an unpredictable sequence of colours. Skin reflects them; the camera sees the reflection. The measurement is the correlation between what was emitted and what came back off the skin, searched over a small lag because the camera shows a change a frame or two after the screen makes it.

Two constraints shaped the implementation. Flashing screens can trigger photosensitive seizures, so the step rate is held at two transitions per second - below the three-flashes-per-second threshold in WCAG 2.3.1 - using muted colours and no saturated red. And the sequence has to be unpredictable, which in a real deployment means server-generated.

Parallax, via homography residual

This is the part I find elegant, because it turns a fuzzy question into a clean test.

When a flat surface rotates, its image can only change by a single projective transform - a homography. That is a mathematical fact, not an approximation. A real face cannot be described that way, because its features sit at different depths and move by different amounts.

So: capture facial landmarks at two head angles, fit the best homography between them, and measure what it fails to explain.

One implementation detail matters enormously. MediaPipe reports a z value per landmark, but it comes from fitting a canonical 3D face model - so it describes depth even for a flat photograph. Using it would defeat the entire test. Only the 2D image coordinates are used.

What the numbers said

I validated the homography test against synthetic geometry before trusting it: a known transform, a flat point cloud, and a 3D head, all projected through a pinhole camera.

Test Residual
Known synthetic homography (solver correctness)3.8×10-14%
Flat surface rotated 10-25°~10-14% (machine zero)
Synthetic 3D face rotated 10-25°2.2% - 5.5%

Then I added realistic landmark jitter, and that is where the first real problem appeared:

Landmark jitter Flat photo Real face
±0.5 px0.33%3.95%
±1.0 px0.68%4.04%
±2.0 px1.35%4.18%

Tracking jitter is the only thing that stops a flat surface reading as flat, and at ±2 px it was creeping toward my pass mark. The fix was to attack it at source: capture each pose as the mean of about 13 frames rather than trusting one. Jitter falls as the square root of the sample count, and the flat case dropped to 0.37%.

On a real face, in a real room, the check measures 8.16% against a 3.0% pass mark. Real faces have considerably more depth than my synthetic model - a comfortable margin.

Three things that surprised me

A printed photo passes the illumination test outright

Paper reflects light. Of course it does. In testing, a print scored 1.00 on illumination - a perfect result. It is only flat, so parallax catches it.

This is why the two signals are scored on the weaker of the pair rather than their average. Averaging would let a strong illumination score paper over no depth at all, which is precisely the printed-photo attack. It also produced a genuine bug: when a flat spoof failed to complete the movement stage, an early version scored it on illumination alone and would have passed it. Parallax had to become mandatory - an incomplete run can never be a pass.

A flat surface cannot even reach the turn threshold

The head-turn detector uses a yaw estimate derived from the relative positions of the nose and the face edges - which is itself depth-dependent. On a flat photograph it barely responds:

Physical rotation Yaw estimate, real face Yaw estimate, flat photo
19°0.28 ✓0.04
45°0.10
60°0.12

Rotating a photo further never gets it there, because the response comes only from weak perspective foreshortening. Good for security - but it meant flat spoofs hung forever rather than failing, with no verdict. Anything that can stall needs a deadline, and the deadline has to name the stage that actually stalled rather than blaming a step the user never reached.

My own test harness was the attack

To test the pipeline end to end I wrote a synthetic subject: a 3D head model, projected at various angles, with skin colour computed as a linear response to the emitted illumination. It scored 1.00.

It took me longer than it should have to notice what that meant. There was no camera involved. There was no face. It was arithmetic, and the system called it a live human.

Twenty-eight lines

So I wrote the attack properly. The entire forger, excluding comments, is 28 lines of JavaScript. It needs nothing that is not public: a generic 3D head shape, and the fact that skin reflects light roughly linearly.

// Project a generic head at a yaw angle. This is the whole "parallax" forgery.
function landmarksAt(yawDeg, noseDepth){
  const t = yawDeg * Math.PI / 180, c = Math.cos(t), s = Math.sin(t);
  return head(noseDepth).map(P => {
    const X = c*P[0] + s*P[2], Z = -s*P[0] + c*P[2], w = Z + DIST;
    return [320 + F*X/w, 240 + F*P[1]/w];
  });
}
// Skin reflecting the screen. This is the whole "illumination" forgery.
const skinUnder = e => ({ r: 44 + 0.30*e[0], g: 33 + 0.26*e[1], b: 28 + 0.22*e[2] });

The first attempt was rejected - my head model was too crude and produced only 1.92% residual. But the thresholds are in the client source, so an attacker can tune locally against them. Searching two parameters took milliseconds:

Real capture, real person Forged, no camera
Parallax residual8.16%6.66%
Illumination correlation0.771.000
VerdictLiveLive

The forgery is cleaner than reality. You might think to reject suspiciously perfect responses - but the attacker simply adds noise, and now you are in an arms race where they control every byte you receive. There is no floor to that.

Why "just send the landmarks" does not work

The obvious response is to move verification to a server. It does not help on its own, and the reason generalises:

Anything the client computes, the client can fabricate the answer to. Landmarks are not evidence - they are a claim about evidence.

Encrypting or signing that claim changes nothing either. Cryptography proves who said something; it never proves that what they said is true. An attacker can satisfy Face ID honestly with their own face, obtain a genuine signed assertion, and separately forge the liveness numbers. Both are valid. Only one is a lie, and the lie is not in the signed part.

There is also a privacy sting in the tail. A 468-point face mesh at pixel precision is a detailed geometric signature - the same class of artefact face recognition uses as a template, and rich enough that you can render a recognisable head from it. "We only send the markers, not the face" is not the privacy win it sounds like. You would be uploading a biometric template while believing you had avoided biometrics.

The privacy trap

Which brings us to the central tension, and it is structural rather than a design flaw:

A verifier that never sees evidence is trusting the client. "Server-verified" and "nothing leaves the device" are in direct opposition.

Zero-knowledge proofs look like the escape and are not. ZK proves computation over some input, not that the input came from a camera - feed it a synthesised face and you get a perfectly valid proof. The provenance problem is untouched.

What you can do is minimise. Full frames are far more than verification needs:

Check Captured Actually needed
Illumination~300 frames, 640×480 colour20 patches at 48×48 — ~10 KB
Parallax3 crops at 160×160 — ~30 KB

A 48×48 patch of forehead skin is not a face. The parallax crops are the stubborn part: they need enough face to locate landmarks, so they remain identifiable.

What a server deployment actually requires

If you do go server-side, the design has to defeat four attacks, not one:

Attack Countered by
Forge the verdictClient never produces one; server mints a signed token
Replay a good sessionServer-generated single-use nonce, bound session, consumed on use
Pre-compute against a known challengeSequence derived from the nonce and revealed step by step, never up front
Upload plausible numbersServer re-derives every measurement from the images itself

That last row is the one most designs get wrong, and it drives the architecture. The server must run its own landmark detection on the uploaded pixels and its own homography fit. Nothing the client computed is trusted, which means a face-detection model running server-side and a real per-verification compute cost.

The latency budget is the only genuine lever against injection. Reveal illumination step k, then require the response within a tight window. The attacker must receive the colour, relight a synthetic face and return frames inside it. That does not make injection impossible - I have been clear it is not solvable in a browser - it prices it.

Finally, the client receives a signed token rather than a verdict: scoped to one session and one action, short-lived, single-use, verified downstream by the application backend. The client cannot produce a passing result because it never produces a result at all.

How a dedicated app changes everything

There is exactly one architecture that gets both privacy and assurance, and it is not a web page.

In a native app: all frames stay on the device, verification runs locally, and the result is signed by a key held in the Secure Enclave or StrongBox that never leaves the hardware. App Attest on iOS or Play Integrity on Android attests the app binary and device integrity, so the server knows which code produced the signature.

The server then trusts a locally computed result because it can verify what computed it. No pixels move. This is the only way to have both, and it is unavailable on the web because there is no equivalent attestation for camera provenance.

It is not absolute. A rooted device with a kernel-level virtual camera still injects, and attestation covers the app rather than the camera feed. But it is a different league from a browser, and it is what the serious products do.

What none of this solves: device farms

Passkeys are the obvious companion, and they are genuinely useful - a hardware-backed, non-exportable key, attested per device. But two limits matter.

First, UV=true is not liveness. When Face ID succeeds, WebAuthn sets the user-verified flag - but a PIN sets exactly the same flag, and the spec deliberately does not tell you which modality was used. You can never distinguish "a live face was verified" from "someone typed the passcode".

Second, and more fundamentally: a farm of N real devices gets N legitimately enrolled passkeys. Every assertion is genuine. Passkeys do not solve that, and neither do Private Access Tokens - both attest possession of hardware, and hardware can be bought.

What they change is the economics: an unbounded attack (a script, free, infinite) becomes a bounded one (capital outlay per identity, attributable, revocable). That is real, but it is not a wall.

There is no cryptographic mechanism that separates "a real person doing a legitimate thing on their own phone" from "a paid worker doing an illegitimate thing on a phone they own". At the level of device attestation those are the identical event.

The defence against farms is economic and behavioural, not cryptographic: cost per attempt, reputation over time, correlated-behaviour detection, and limiting what a fresh account can do before it has earned trust.

Accessibility and safety are not add-ons here

Two constraints shaped the build more than any security consideration.

The whole check is completable without looking at the screen. A blind user cannot see whether they are framed, lit or facing the right way, so speech alone is insufficient - it is too slow to close a feedback loop on a moving target. The harness pairs spoken instructions with a continuous guidance tone that rises in pitch and repeat rate as the error falls, like a parking sensor: 784 ms between pips at 500 Hz when far off, tightening to 160 ms at 900 Hz as you close in, then a distinct steady tone when in position.

Direction also had to go. "Turn left" depends on a landmark index convention I could not verify without hardware, so the check asks you to turn either way first, then the other. That removed an entire class of bug and is kinder to anyone whose neck turns further one way.

Flashing screens can trigger seizures. WCAG 2.3.1 sets a three-flashes-per-second threshold; the illumination stage runs at two transitions per second with muted colours and no saturated red, and it can be switched off entirely. That constraint caps how much challenge bandwidth is available, which is a real security cost paid for a real safety reason.

And the honest limitation: the movement test needs enough neck rotation to register, so it will fail some users for reasons unrelated to liveness. Any deployment needs an alternative route - and that route immediately becomes the weakest link, exactly as audio CAPTCHAs became the preferred target.

What I would actually build

To stop spam and abuse: passkeys where available, Private Access Tokens where not, behavioural and reputation signals as the backbone. No camera, no biometrics, no legal exposure, no accessibility harm. Liveness never fitted this threat model.

For high-assurance moments - account recovery, re-enrolment, high-value transactions - do the expensive verification once, with real pixels, server verification and explicit consent. Then bind a passkey to that verified identity and carry it forward cryptographically. Total biometric processing across a user's lifetime drops to a single consented event, and everything afterwards is a hardware-backed signature.

That inverts the privacy problem instead of trying to engineer around it, which after all of the above seems like the only honest way through.

What the experiment was worth

The optics work. Controlled illumination and homography residual are sound, cheap, and measurably separate a real face from a photo, a screen and a mask. On real hardware the margin is comfortable - 8.16% against a 3.0% mark, 0.77 against 0.60.

And none of that matters if the thing reporting the numbers is the thing being tested. The most valuable output of this experiment was not the check. It was the 28 lines that beat it, and the clarity that came from writing them.

Do not deploy this as a security control on its own. The harness is published so the measurements can be reproduced and argued with; the protection has to come from the server-side and attestation work described above. Every threshold in it is provisional and calibrated against synthetic geometry rather than real spoof captures. If you need liveness for something that matters, the article sets out what that actually costs.

The harness is here. It will happily tell you that you are a real person. It will also tell 28 lines of arithmetic exactly the same thing.