Recovery / No CAPTCHA
Step-up checks
When a server verification returns challenge or limit, offer proof of work or an email code. No CAPTCHA, passkey, card or phone is required.
Flow
- Call
POST /v1/verifyfrom your server. Store the returnedrefwith the pending application action. - Return
{error: 'challenge-required', ref}to the browser. - Call
Nolgard.stepUp({ref})in the browser. An accessible dialog runs the check. - Send the returned
nfs_…token to your server. - Call
POST /v1/stepup/verifyfrom your server. Checksuccessand confirm thatrefmatches the pending action before granting access.
Browser
Load the client with your site's public key. Step-up requests use that key and its allowed origins.
<script src="https://nolgard.com/v1/client.js"
data-site-key="pk_…" defer></script>
// Proof of work (default)
const token = await Nolgard.stepUp({ ref: result.ref });
// Email code. Omit email to let the dialog ask for it.
const emailToken = await Nolgard.stepUp({
ref: result.ref, method: 'email', email: 'jane@example.com'
});
// Send token to your own backend. Esc / Cancel rejects the promise.
Proof of work uses SHA-256 at difficulty 22, targeting roughly 3–5 seconds on a laptop. Runtime depends on hardware and chance. The dialog shows progress and lets you cancel. An email code proves control of the entered inbox. It says nothing about ownership of an existing application account.
Server
const response = await fetch('https://nolgard.com/v1/stepup/verify', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: process.env.NOLGARD_SECRET, token })
});
if (!response.ok) throw new Error('Step-up verification unavailable');
const proof = await response.json();
if (!proof.success || proof.ref !== pendingAction.nolgardRef) {
throw new Error('Invalid step-up proof');
}
// Bind pendingAction to the current authenticated session, then grant access.
// An email check additionally returns emailVerified: true.
Tokens are site-bound and single-use. Never trust a result claimed by the browser. Keep the original ref on your server; do not replace it with one from the browser. The runnable Node example (examples/verify-node.mjs) returns the challenge ref. Add a session-bound continuation endpoint to consume step-up proofs.
API
POST /v1/stepup/start
{ "siteKey": "pk_…", "ref": "ev_123", "method": "pow" }
200 { "id": "su_…", "method": "pow", "challenge": "…", "difficulty": 22 }
{ "siteKey": "pk_…", "ref": "ev_123", "method": "email", "email": "jane@example.com" }
200 { "id": "su_…", "method": "email", "sentTo": "j•••@example.com" }
The ref must belong to this site and identify a verify event less than 30 minutes old. Its decision, or observe-mode wouldDecide, must be challenge or limit. Demo is read-only. Configure email delivery before using email checks. Delivery failures return 503 instead of claiming that a code was sent.
POST /v1/stepup/complete
{ "siteKey": "pk_…", "id": "su_…", "solution": "1234567" }
// or { "siteKey": "pk_…", "id": "su_…", "code": "123456" }
200 { "token": "nfs_…" }For proof of work, solution is a decimal nonce: SHA-256 of challenge + ':' + solution must have at least 22 leading zero bits. Start and complete enforce the site's origin allowlist, like /v1/challenge.
POST /v1/stepup/verify
{ "secret": "sk_…", "token": "nfs_…" }
200 { "success": true, "ref": "ev_123", "method": "email", "emailVerified": true }
// Invalid, expired, replayed or foreign token:
200 { "success": false }
// Wrong secret:
401 { "error": "invalid-secret" }Server endpoint CORS is *. Proof-of-work results omit emailVerified. Documented test secrets accept only nfs_test, returning ref: 'ev_test' and method: 'pow', without storage or billing. Test proofs never work with production secrets.
Limits and storage
| Limit | Value |
|---|---|
| Ref age | Under 30 minutes |
| Pending proof / code | 10 minutes |
| Issued token | 10 minutes, single-use |
| Emails per ref | 3, including failed sends |
| Email starts per site | 100 per rolling hour |
| Attempts per check | 5 |
Email codes are stored as keyed hashes only. Email addresses go to the mail provider but are not retained in the step-up table. Tokens are stored as hashes only. Raw IPs are not stored. Check records stay for 30 days for dashboard counts, then cron prunes them. Passing a check adds features.stepUp = {method, ts} to the original event. The event drawer shows Step-up. The overview shows passed / started counts for the selected range.