/api/v1/captcha
The proof-of-work captcha that stands in front of registration and of
Eternaltwin sign-in. Defined in crates/rest/src/captcha.rs.
It is a proof of work, not a picture to squint at: the browser is given a
signed challenge, spends a couple of seconds hashing, and trades the answers
for a short-lived verification token. The token then travels in the protected
form as cap-token.
The shape of the routes
Both routes carry a scope as their first path segment:
| Scope | Guards |
|---|---|
register | POST /api/v1/users |
login | PUT /api/v1/auth/self |
The scope sits in the path rather than in a body because the widget is
configured with a single base URI and appends challenge and redeem to it:
data-cap-api-endpoint="/api/v1/captcha/register/" → POST /api/v1/captcha/register/challenge → POST /api/v1/captcha/register/redeem
A token minted for one scope is refused by the other form, so a captcha solved on the sign-in page cannot be spent on the registration one.
Neither route is authenticated, and neither is rate-limited. Issuing a challenge is one signature and redeeming one is fifty hashes, while the work between them costs the caller seconds: the exchange is already far more expensive for whoever abuses it than for the server.
Whether the captcha is required at all is published by
config as captcha.enabled.
POST /:scope/challenge
Issues a challenge. Takes no body — the widget posts nothing, and an extractor that insisted on a body would reject every one of its requests.
Example
POST /api/v1/captcha/register/challenge
{
"token": "eyJ…",
"challenge": {
"c": 50,
"s": 32,
"d": 4
},
"expires": 1768486634015
}
| Field | Type | Meaning |
|---|---|---|
token | string | The signed challenge. Handed back at redemption. |
challenge.c | integer | Number of sub-puzzles. |
challenge.s | integer | Salt length, in hexadecimal characters. |
challenge.d | integer | Target prefix length, in hexadecimal characters. Each extra character multiplies the expected work by 16. |
expires | integer | When the challenge stops being redeemable, in milliseconds since the epoch. |
The puzzles are described, not listed: both ends derive the c salts and
targets from token with the same hash, so the response stays a few dozen
bytes whatever c is. The defaults are fifty puzzles of four hexadecimal
characters, roughly two to three seconds of work in a browser.
Errors
| Status | Body | When |
|---|---|---|
| 500 | {"error": "captcha is misconfigured"} | The configured parameters are out of range. |
| 500 | {"error": "internal error"} | Signing failed. |
POST /:scope/redeem
Exchanges the solved puzzles for a verification token.
Body
| Field | Type | Meaning |
|---|---|---|
token | string | The challenge token, unchanged. |
solutions | array of integers | One nonce per sub-puzzle, in order. |
Example
POST /api/v1/captcha/register/redeem
Content-Type: application/json
{"token": "eyJ…", "solutions": [11724, 43, 90781]}
{
"success": true,
"token": "b0e1…",
"expires": 1768486934015
}
success is always true: a refused redemption is an error response, not a
success: false body. The field exists because the widget expects it.
The scope in the path is not trusted for anything — the challenge token states its own scope, and that is what the minted token inherits. The path is checked against it all the same, because the two disagreeing means a page is wired to the wrong endpoint.
Errors
Every refusal is 400; the widget shows its own message and offers a retry for any 4xx, so which one it is only matters to whoever reads the logs.
| Status | Body | When |
|---|---|---|
| 400 | {"error": "invalid captcha challenge"} | The challenge token is forged or expired. |
| 400 | {"error": "captcha challenge was issued for another scope"} | The path and the token disagree. |
| 400 | {"error": "invalid captcha solutions"} | At least one nonce does not answer its puzzle. |
| 400 | {"error": "captcha challenge was already redeemed"} | The challenge was spent. |
| 500 | {"error": "internal error"} | — |
Spending the token
The verification token goes into the protected request as a field named
cap-token, beside the rest of the body:
POST /api/v1/users
Content-Type: application/json
{
"username": "alice",
"display_name": "Alice",
"password": "61616161616161616161",
"cap-token": "b0e1…"
}
The name comes from the widget, which writes a hidden input called cap-token
inside a <form>; the JSON path uses the same name so that both request shapes
read alike.
A protected endpoint reports a captcha problem with two statuses, and the wording is part of the contract — the sign-in page matches the string in order to tell a refused captcha apart from a wrong password:
| Status | Body | When |
|---|---|---|
| 401 | {"error": "captcha is required"} | The field is absent while the captcha is enabled. |
| 403 | {"error": "captcha is invalid"} | Forged, expired, issued for another form, or already spent. |
Registration always asks for a captcha. Sign-in asks for one only once the client address has got a password wrong several times within the last half hour; see auth/self.