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

  1. Call POST /v1/verify from your server. Store the returned ref with the pending application action.
  2. Return {error: 'challenge-required', ref} to the browser.
  3. Call Nolgard.stepUp({ref}) in the browser. An accessible dialog runs the check.
  4. Send the returned nfs_… token to your server.
  5. Call POST /v1/stepup/verify from your server. Check success and confirm that ref matches 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

LimitValue
Ref ageUnder 30 minutes
Pending proof / code10 minutes
Issued token10 minutes, single-use
Emails per ref3, including failed sends
Email starts per site100 per rolling hour
Attempts per check5

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.