Skip to content

Instantly share code, notes, and snippets.

@toomanyredirects
Created December 10, 2025 04:15
Show Gist options
  • Select an option

  • Save toomanyredirects/5398405c9bc311730a9b7c8b94a9be14 to your computer and use it in GitHub Desktop.

Select an option

Save toomanyredirects/5398405c9bc311730a9b7c8b94a9be14 to your computer and use it in GitHub Desktop.
Proof-of-Work, Privacy-First Captcha (Lightweight, Accessible with ES5 Fallback for WebWorkers)
<form id="form" name="form" method="post" actin="">
<legend>Verification</legend>
<fieldset>
<div id="captcha" data-i18n-check="Checking captcha solution..." data-i18n-success="The captcha check was successful." data-i18n-error="The proof of work failed: Try again." data-endpoint-uri="/api/v1/captcha/" data-timeout-sec="15" aria-label="Click to verify captcha." aria-live="polite" hidden>
<input type="checkbox" id="captcha-checkbox" name="captcha" disabled/>
<label for="captcha-checkbox" role="status" data-i18n-error="Error checking captcha." data-i18n-network="Network not available." data-i18n-success="You are a human." data-i18n-progress="Verifying %per% ...">I am human.</label>
<p class="message message-error" role="alert">Captcha verification required.</p>
</div>
<fieldset>
<fieldset>
<button type="reset">Reset</button>
<button type="submit">Submit</button>
</fieldset>
</form>

Proof-of-Work, Privacy-First Captcha (Lightweight, Accessible with ES5 Fallback for WebWorkers)

Proof-of-Work, Privacy-First Captcha

Lightweight, Unobtrusive, Semantic, Accessible with ES5 Fallback for WebWorkers

The Captcha check needs an API endpoint that can handle SHA-256 and session storage, preferably PHP with ngix and/or Apache.

@version 1.3 @creationdate 03.12.2025, 09:15h @revisiondate 05.12.2025, 21:51h @author Dan (dirkdigweed@gmx.net)

Free to use but buy me a coffee through PayPal if you use it, I can also provide you with the Server-side part of the script (PHP or nodes.js) if you do so.

A Pen by Dan on CodePen.

License.

/**
* captcha.js
*
* Proof-of-Work, Privacy-First Captcha
* Lightweight, Unobtrusive, Semantic, Accessible with ES5 Fallback for WebWorkers
*
* The Captcha check needs an API endpoint that can handle SHA-256
* and session storage, preferably PHP with ngix and/or Apache.
*
* @version 1.3
* @creationdate 03.12.2025, 09:15h
* @revisiondate 05.12.2025, 21:51h
* @author Dan (rakooooon@live.com)
*/
(function Captcha () {
var box = document.getElementById('captcha');
if (!box) return; // Early bail
var input = box.querySelector('#captcha-checkbox'),
label = box.querySelector('[for='+ input.id +']'),
apiuri = box.getAttribute('data-endpoint-uri');
if(!input || !label || !apiuri) return; // Late bail
// Timeout (15 seconds default)
var timeout = parseInt(box.getAttribute('data-timeout-sec') || 15) * 1000,
inTimeout = false;
// Track live workers for termination
var workers = [];
// i18n - Internationalization
var i18n = {
box: {
default: box.getAttribute('aria-label') || 'Click to verify captcha.',
check: box.getAttribute('data-i18n-check') || 'Checking captcha solution...',
success: box.getAttribute('data-i18n-success') || 'The captcha check was successful.',
error: box.getAttribute('data-i18n-error') || 'The captcha check failed: Try again.'
},
label: {
default: label.innerText,
network: label.getAttribute('data-i18n-network') || 'Network not available.',
progress: label.getAttribute('data-i18n-progress') || 'Verifying %per% ...',
error: label.getAttribute('data-i18n-error') || 'Error checking captcha.',
success: label.getAttribute('data-i18n-success') || 'You are a human.'
}
};
// Progress circle svg
var progress = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
progress.setAttribute('role', 'presentation');
progress.setAttribute('viewBox', '0 0 9 9');
progress.innerHTML = '<circle cx="4.5" cy="4.5" r="4"/><circle cx="4.5" cy="4.5" r="4"/>';
// Enable captcha
box.removeAttribute('hidden');
box.setAttribute('aria-live', 'polite');
input.disabled = false;
input.required = true;
label.setAttribute('role', 'status');
// Hook up reset button if available
var resetBtn = input.form.querySelector('[type=reset]');
if (resetBtn) resetBtn.addEventListener('click', reset, false);
// Minimal bot‑detection layer alongside e.isrusted
var challengeStart = null;
// Label event listener
label.addEventListener('click', function (e) {
// Require a genuine user interaction
if (!e.isTrusted) {
e.preventDefault();
return;
}
if (!input.checked) {
e.preventDefault();
challengeStart = Date.now();
start();
}
});
// Helper functions
function reset () {
if (inTimeout) clearTimeout(inTimeout);
inTimeout = false;
challengeStart = null;
box.removeAttribute('data-status');
box.setAttribute('aria-label', i18n.box.default);
input.indeterminate = false;
input.disabled = false;
input.checked = false;
input.removeAttribute('required');
input.classList.remove('invalid');
if (input.setCustomValidity) input.setCustomValidity('');
input.required = true;
if (progress.parentNode) progress.parentNode.removeChild(progress);
label.innerText = i18n.label.default;
// Clean up possibly started workers
if (workers.length) terminateWorkers();
}
function handleError (status, labelText) {
box.setAttribute('data-status', status);
box.setAttribute('aria-label', i18n.box.error);
box.removeAttribute('aria-busy');
input.indeterminate = false;
label.innerText = labelText;
if (inTimeout) clearTimeout(inTimeout);
inTimeout = setTimeout(function(){ reset(); }, timeout);
if (progress.parentNode) progress.parentNode.removeChild(progress);
// Clean up possibly started workers
if (workers.length) terminateWorkers();
}
function terminateWorkers () {
for (var j = 0; j < workers.length; j++) workers[j].terminate();
workers.length = 0;
}
// Main captcha functiona
function start () {
// Prepare input
input.classList.remove('invalid');
input.defaultValue = null;
// NOT working as per now:
if (input.setCustomValidity) input.setCustomValidity('');
input.indeterminate = true;
box.setAttribute('data-status', 'start');
box.setAttribute('aria-label', i18n.box.check);
box.appendChild(progress);
label.innerText = i18n.label.progress.replace('%per', 0);
// Start fetching / XHR
var xhr = new XMLHttpRequest();
xhr.open('GET', apiuri + 'start', true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onreadystatechange = function () {
// Success
if (xhr.readyState === 4 && xhr.status === 200) {
var res;
try { res = JSON.parse(xhr.responseText); }
catch (e) { res = null; }
// Handle already-verified session
if (res && res.data && res.data.verified === true) {
input.indeterminate = false;
input.disabled = false;
input.checked = true;
box.setAttribute('data-status', 'success');
box.setAttribute('aria-label', i18n.box.success);
label.innerText = i18n.label.success;
if (progress.parentNode) progress.parentNode.removeChild(progress);
return;
}
// Continue with challenge
if (res && res.data && res.data.nonce && res.data.difficulty) {
check(res.data.nonce, res.data.difficulty);
} else {
handleError('error', i18n.label.error);
}
}
// Error handling of non-200 HTTP statuses
else if (xhr.readyState === 4) {
var res;
try { res = JSON.parse(xhr.responseText); }
catch (e) { res = null; }
// Server API errors
if (res && res.errors) {
console.log('Captcha start failed: ' + res.errors);
handleError('error', i18n.label.error);
}
// Connection / network errors (onerror / on timeout)
else handleError('warning', i18n.label.network);
}
};
// Network error handler
xhr.onerror = function () { console.log('Captcha start failed: network error'); };
xhr.ontimeout = function () { console.log('Captcha start failed: timeout'); };
xhr.timeout = timeout;
xhr.send();
}
function check (nonce, difficulty) {
if (!challengeStart) { reset(); return; }
var interval = 1000;
box.setAttribute('data-status', 'check');
box.setAttribute('aria-label', i18n.box.check);
box.setAttribute('aria-busy', 'true');
function countLeadingZeroBits(buffer) {
var bytes = new Uint8Array(buffer);
var bits = 0;
for (var i = 0; i < bytes.length; i++) {
var val = bytes[i];
if (val === 0) { bits += 8; }
else {
var tz = 0;
while ((val & 1) === 0 && tz < 8) { tz++; val >>= 1; }
bits += tz;
break;
}
}
return bits;
}
// Modern Webworker on SSL
if (window.Worker && location.protocol === 'https:') {
var numWorkers = navigator.hardwareConcurrency || 2,
solved = false;
var workerCode = `
${countLeadingZeroBits.toString()}
self.onmessage = async function(e) {
var nonce = e.data.nonce,
interval = e.data.interval,
difficulty = parseInt(e.data.difficulty, 10),
enc = new TextEncoder(),
start = e.data.start,
step = e.data.step,
solution = start;
while (true) {
var data = enc.encode(nonce + solution),
digest = await crypto.subtle.digest('SHA-256', data),
bits = countLeadingZeroBits(digest);
if (bits >= difficulty) {
self.postMessage({ solution: solution });
break;
}
solution += step;
if (solution % interval === 0) {
var percent = Math.min(100, Math.round((solution / (1 << difficulty)) * 100));
self.postMessage({ progress: percent });
}
}
};
`;
var blob = new Blob([workerCode], { type: "application/javascript" }),
url = URL.createObjectURL(blob);
for (var i = 0; i < numWorkers; i++) {
var worker = new Worker(url);
workers.push(worker);
worker.onmessage = function(e) {
if (e.data.solution !== undefined && !solved) {
var s = e.data.solution,
enc2 = new TextEncoder();
crypto.subtle.digest('SHA-256', enc2.encode(nonce + s)).then(function(d){
var bits = countLeadingZeroBits(d);
if (bits >= difficulty) {
solved = true;
verify(s);
box.removeAttribute('aria-busy');
for (var j = 0; j < workers.length; j++) workers[j].terminate();
workers.length = 0;
URL.revokeObjectURL(url);
}
});
} else if (e.data.progress !== undefined && !solved) {
var percent = e.data.progress;
box.style.setProperty('--progress', percent);
label.innerText = i18n.label.progress.replace('%per', percent);
}
};
worker.postMessage({ nonce: nonce, difficulty: difficulty, start: i, step: numWorkers, interval: interval });
}
}
// ES5 fallback
else {
var solution = 0, batchSize = 200, solved = false;
function loop() {
var count = 0;
while (count < batchSize && !solved) {
var hex = sha256Hex(nonce + solution),
bytes = new Uint8Array(hex.match(/.{2}/g).map(function(h){ return parseInt(h, 16); })),
bits = countLeadingZeroBits(bytes.buffer);
if (bits >= difficulty) {
solved = true;
verify(solution);
box.removeAttribute('aria-busy');
break;
}
solution++;
count++;
if (solution % interval === 0) {
var percent = Math.min(100, Math.round((solution / (1 << difficulty)) * 100));
box.style.setProperty('--progress', percent);
label.innerText = i18n.label.progress.replace('%per', percent);
}
}
if (!solved) setTimeout(loop, 0);
}
loop();
}
}
function verify (solution) {
var xhr = new XMLHttpRequest();
xhr.open('POST', apiuri + 'verify', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
var res;
try { res = JSON.parse(xhr.responseText); }
catch (e) { res = null; }
// Success path
if (xhr.status === 200 && res && res.success && res.data && res.data.verified) {
box.style.setProperty('--progress', 100);
box.removeAttribute('aria-busy');
box.setAttribute('data-status', 'success');
box.setAttribute('aria-label', i18n.box.success);
input.indeterminate = false;
input.disabled = false;
input.checked = true;
input.value = 'verified';
label.innerText = i18n.label.success;
// Always remove progress svg
if (progress.parentNode) progress.parentNode.removeChild(progress);
}
// Error handling of non-200 HTTP statuses or failed verification
else {
// Server API errors
if (res && res.errors) {
console.log('Captcha verify failed: ' + res.errors);
handleError('error', i18n.label.error);
}
// Connection / network errors (onerror / ontimeout)
else handleError('warning', i18n.label.network);
}
}
};
// Network error handler
xhr.onerror = function () { console.log('Captcha verify failed: network error'); };
xhr.ontimeout = function () { console.log('Captcha verify failed: timeout'); };
xhr.timeout = timeout;
xhr.send('solution=' + encodeURIComponent(solution) + '&ts=' + encodeURIComponent(challengeStart - 1000));
}
})();
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Poppins", sans-serif;
}
*, *::before, *::after { box-sizing: border-box; }
body {
height: 100vh;
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
background: #FFF;
margin:0;
overflow: hidden;
}
form {
width: 80%;
padding: 0;
margin:0;
max-width: 30rem;
display: block;
fieldset {
border: none;
margin:1rem 0 0 0;
padding: 0;
white-space: nowrap;
}
button {
padding: .5rem;
border-radius: .2rem;
border: 1px solid currentcolor;
&:active {
transform: scale(.95);
}
&:focus {
outline: 2px solid currentColor;
outline-offset: 1px;
}
&[type=submit] { float: right; }
&[type=reset] { float: left; }
&:first-of-type { margin: 0 1rem 0 0; }
}
}
/// Captcha Design
$captcha-hover-bg: rgba(lightgray, .5) ;
$captcha-error-color: darkred;
$captcha-warning-color: orange;
$captcha-success-color: green;
$captcha-check-svg: "%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='9' viewBox='0 0 9 9'%3E%3Cstyle%3E@keyframes anim{0%25{stroke-dashoffset:8.718px}to{stroke-dashoffset:0}}%3C/style%3E%3Cpath fill='none' stroke='%2300a67d' stroke-linecap='square' stroke-linejoin='round' stroke-width='1' d='M1.9 4.5 3.8 6.3 7.5 2.6' style='stroke-dashoffset:0;stroke-dasharray:8.718px;animation:anim .5s ease'/%3E%3C/svg%3E";
$captcha-error-svg: "%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='9' viewBox='0 0 9 9'%3E%3Ccircle cx='4.5' cy='4.5' r='4' fill='none' stroke='#{$captcha-error-color}' stroke-width='0.8'/%3E%3Cpath stroke='#{$captcha-error-color}' stroke-width='0.8' stroke-linecap='square' d='M3 3 L6 6 M6 3 L3 6'/%3E%3C/svg%3E";
$captcha-warning-svg: "%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 9 9'%3E%3Cpath d='M4.5 0.5 L8.5 8.5 H0.5 Z' fill='none' stroke='#{$captcha-warning-color}' stroke-linejoin='round' stroke-width='0.8'/%3E%3Cpath d='M4.5 3.5 L4.5 5.5 M4.5 6.9 L4.5 7' stroke-width='0.8' stroke='#{$captcha-warning-color}' stroke-linecap='square'/%3E%3C/svg%3E";
#captcha {
--progress: 0;
// To do: change all inside units to em so size changes are
// possible on the container with one rem unit chnange.
position: relative;
overflow: visible;
display: inline-block;
width: 100%;
height: 3.5rem;
border: 1px solid #ccc;
padding: 1rem;
line-height: 1.5rem;
border-radius: .2rem;
user-select: none;
&[hidden] {
display: none;
}
&[data-status=start] {
cursor: wait;
}
&[data-status=check] {
cursor: progress;
}
&[data-status=success], &[data-status=warning], &[data-status=error] {
cursor: default;
input {
opacity: 1;
outline: none;
pointer-events: none !important;
}
label {
opacity: 1;
pointer-events: none !important;
}
}
&[data-status=warning] {
input { background-image: url("data:image/svg+xml, #{$captcha-warning-svg}"); }
label { color: $captcha-warning-color; }
}
&[data-status=error] {
input { background-image: url("data:image/svg+xml, #{$captcha-error-svg}"); }
label { color: $captcha-error-color; }
}
&, * { box-sizing: border-box; }
svg {
position: absolute;
left: 1rem;
top: 1rem;
width: 1.5rem;
height: 1.5rem;
margin:0;
circle {
fill: transparent;
stroke-width: .8;
stroke-linecap: square;
stroke: currentColor;
&:first-of-type {
opacity: .15;
}
&:last-of-type {
transform: rotate(-90deg);
transform-origin: 50% 50%;
stroke-dasharray: 25.13; // circumference circle: 2πr for r=4
stroke-dashoffset: calc(25.13 - (var(--progress) / 100 * 25.13));
}
}
}
input {
all: unset;
appearance: none;
-moz-appearance: textfield;
width: 1.5rem;
height: 1.5rem;
outline: 2px solid currentColor;
opacity: .5;
overflow: visible;
border: none;
border-radius: .25rem;
background-color: transparent;
background-position: center center;
background-size: 100%;
pointer-events: none;
z-index: -1;
will-change: opacity, background;
animation-play-state: paused;
transition: opacity .2s ease;
&:active, &:focus { opacity:1; }
&:disabled {
& + label {
cursor: not-allowed;
opacity: .25;
pointer-events: none;
}
}
&:indeterminate {
visibility: hidden;
& + label {
pointer-events: none;
opacity: 1;
}
}
&:checked {
opacity:1;
outline: none;
pointer-events: none !important;
animation-play-state: running;
background-size: 115%;
background-image: url("data:image/svg+xml, #{$captcha-check-svg}");
& + label{
opacity:1;
pointer-events: none !important;
pointer-events: none;
}
}
&:user-invalid, &.invalid {
outline-color: $captcha-error-color;
& ~ .message-error {
opacity: 1;
visibility: visible;
display: block;
}
}
}
label {
position: absolute;
overflow: hidden;
top: 0; bottom: 0; left:0;
width: 100%;
padding: inherit;
padding-left: 3.5rem;
margin: 0;
line-height: inherit;
cursor: pointer;
white-space: nowrap;
text-overflow: ellipsis;
will-change: background, opacity;
transition: background .2s ease, opacity .2s ease;
&:hover { background-color: $captcha-hover-bg; }
&:first-letter { text-transform: uppcase; }
}
.message-error {
position: absolute;
display: none;
overflow: visible;
margin: 0; padding: 0;
left: 0; bottom: -1.5em;
color: $captcha-error-color;
font-size: 0.7rem;
line-height: 1em;
opacity: 0;
visibility: hidden;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment