cd /news/computer-vision/my-spoof-gate-rejected-every-attack-… · home › topics › computer-vision › article
[ARTICLE · art-156424] src=dev.to ↗ pub= topic=computer-vision verified=true sentiment=· neutral

My spoof gate rejected every attack. It was rejecting them for the wrong reason.

A developer building a face-recognition spoof gate discovered that its liveness check was rejecting printed-photo and screen-replay attacks for the wrong reason: the strobe-detection channel sampled video at 10 Hz while the phone flashlight pulsed at roughly 10 Hz, so aliasing produced a clean 0.0 alternation score that looked like a confident rejection. Recomputing alternation at the clip's native 20 fps raised the same clip's post-prompt score from 0.0 to 1.000, and a sweep of 14 prompt times showed the looped-flash attack's pre-prompt alternation never fell below 0.62, meaning it is caught by the pre-window check rather than a lucky quiet window. The developer also moved identity matching off the 10 Hz stride to native rate (30–45 samples per 1.5 s decision window) and now requires every rejection report to name the specific check that fired.

by read3 min views1 publishedOct 23, 2026

A face-recognition gate I built rejects printed photos, screen replays, and looped video. Last night I found out it was doing it for the wrong reason, and the wrong reason looks identical to the right one from the outside.

Here is the whole thing, measured.

Two channels.

Identity: ArcFace embedding from InsightFace buffalo_l, cosine against a gallery of real faces, threshold 0.45.

Liveness: the operator says "go", and the person flashes their phone flashlight across their face. The gate measures alternation in the red-blue difference of the center crop: the share of consecutive frames where R - B moves by more than 20. A strobe is present after the prompt and absent before it. A recording of a past strobe session is already running when the prompt lands, so its pre-window is never quiet.

A printed photo on a table, filmed while a phone played a flashing app at it. Full-frame R-B range across the clip: 120.6. A real strobe, from a real recording.

The gate's alternation channel reported 0.0. Twice: 0.0 before the prompt, 0.0 after.

It rejected the attack. I almost shipped that number.

The camera recorded at 20 fps. The phone flashlight pulses around 10 Hz. My liveness channel sampled the video at 10 Hz, because 10 Hz was enough for the blink channel and I had reused the same frame stride.

Sampling a 10 Hz square wave at 10 Hz does not capture it. Depending on phase it reads a nearly constant line: every sample lands on the same half of the cycle. I had built a channel that was blind to exactly the signal it existed to find, and the blindness arrived as a clean, confident zero.

So two different situations produced the same verdict:

Both print REJECT. Only one of them says anything about the attacker. If a real human had flashed a real phone at the camera, the same channel would have missed it and false-rejected them. A gate that rejects everything is not secure. It is just closed.

Alternation is now computed at the clip's native frame rate. Same clip, same code, one number changed:

sampling pre-prompt alternation post-prompt alternation
10 Hz stride 0.0 0.0
native 20 fps 0.655 1.000

And the property the design actually needs is not "the loop scores high", it is "no prompt time lets the loop through". I swept it:

attack prompt times swept prompt times where pre is quiet and post is present
printed photo held to camera 19 0
screen replay of a selfie video 28 0
looped flash session 14 0

The loop's pre-prompt alternation never falls below 0.62 at any of the 14 prompt times I tried. There is no "go" I could accidentally pick that hands it a quiet window. It dies by the pre-window check, which is the check that is supposed to kill it.

Identity was on the same 10 Hz stride. Decision window is 1.5 s, so that is 3 samples, and if the lit frames fell between samples the lit-frame mask came back empty and identity returned None. On one clip that surfaced as a straight false reject.

Now identity runs at native rate too: 30 to 45 samples per decision window, and the report prints the window's mean cosine, the whole-clip mean, and the pre-prompt mean separately, so a drag in any one of them is visible instead of averaged away.

Every rejection report now has to name the check that fired, not just the verdict. identity_below_threshold and no_strobe_after_prompt and strobe_already_running are three different findings, and only the third one is a liveness result.

The general form: a negative is not a measurement until you can say what would have produced a positive. If I cannot describe the sample that would pass, I have not built a detector, I have built a closed door.

The code is a Python script, two OpenCV series, one InsightFace model, and a report that prints why.

── more in #computer-vision 4 stories · sorted by recency
── more on @insightface 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/my-spoof-gate-reject…] indexed:0 read:3min 2026-10-23 · —