Security
Replay protection
How one-time challenge consumption, signature timestamps and single-use tokens prevent proofs and tokens from being reused.
Three layers
| Layer | Mechanism | Failure code on replay |
|---|---|---|
| Challenge | Consumed atomically by the first valid proof. Status becomes VERIFIED. | CHALLENGE_CONSUMED |
| Signature | created must be ≥ issued_at − 60, ≤ now + 60 and ≤ expires_at + 60. The base includes the per-challenge nonce, so a signature is useless for any other challenge. | SIGNATURE_EXPIRED |
| Token | Stored by jti; consume: true marks it used. exp = iat + 600. | TOKEN_ALREADY_USED / TOKEN_EXPIRED |
Challenge consumption
Consumption is a conditional update: the row moves from PENDING to VERIFIED only if it is still PENDING. Two agents racing on the same challenge cannot both succeed; the loser receives CHALLENGE_CONSUMED. Failed proofs do not consume the challenge, so an honest agent can correct a mistake, but the counter allows at most 10 attempts before the challenge is rejected for good.
Signature window
The created value is part of the signed base, so it cannot be adjusted after signing. Combined with the 60 s skew this bounds how long a captured proof could be useful, and since the challenge is consumed on first use the window is moot in practice.
issued_at − 60 ≤ created ≤ min(now, expires_at) + 60Token consumption
Call POST /api/v1/tokens/verify with consume: true at the moment you act on the token (e.g. when processing the form submission), not when the widget reports success. A token verified without consume can still be consumed later by whoever holds it.
If you verify tokens against the JWKS without calling the API, keep a set of seen jti values for at least 10 minutes and reject duplicates. The challenge id in jti is unique.
Widget behaviour
window.imnotahuman.reset() and the Retry button always create a new challenge and clear the hidden token input. The widget never reuses a challenge id.