/api/challenge
A two-step challenge-response for proving control of a Bitcoin address. Use it when a gate can't trust the address source (a public header, a query string, an unsigned cookie) — run the challenge first, stash the proven address in a signed session, have the gate read from the session.
The challenge message uses a distinct header (orangecheck-auth) and distinct ack literal so a signed challenge can never be confused with a reputation attestation.
GET /api/challenge — issue
GET https://ochk.io/api/challenge?addr=bc1q...&audience=https://example.com&purpose=login
Query parameters
| Param | Required? | Type | Notes |
|---|---|---|---|
addr | yes | string | Bitcoin address to challenge. |
audience | optional | URL | Origin-binding. If set, verify will require match. |
purpose | optional | string | Label baked into the message (e.g. login, claim). |
ttl | optional | int | Seconds, 30–3600. Default 300. |
Response (200)
{
"message": "orangecheck-auth\naddress: bc1q...\nnonce: ...\n...",
"nonce": "a1b2c3d4e5f6789012345678901234ab",
"expiresAt": 1747584000000,
"expiresAtIso": "2026-04-20T12:30:00Z"
}
What to do with the response
The server issuing the challenge must remember the nonce against the user's session (Redis, signed cookie, DB). On POST, the server passes it as expectedNonce — this defeats replay.
The client passes message to a Bitcoin wallet for signing (BIP-322).
POST /api/challenge — verify
POST https://ochk.io/api/challenge
Content-Type: application/json
Request body
{
"message": "orangecheck-auth\n...",
"signature": "AkcwRAIg...",
"scheme": "bip322",
"expectedNonce": "a1b2c3d4e5f6789012345678901234ab",
"expectedAudience": "https://example.com",
"expectedPurpose": "login"
}
Fields
| Field | Required? | Notes |
|---|---|---|
message | yes | The canonical challenge message as returned by GET. |
signature | yes | BIP-322 or legacy signature. Base64 or hex. |
scheme | optional | "bip322" (default) or "legacy". |
expectedNonce | optional (recommended) | The nonce you stashed at GET time. |
expectedAudience | optional | Require the audience: extension to match. |
expectedPurpose | optional | Require the purpose: extension to match. |
Success (200)
{
"ok": true,
"reason": "ok",
"address": "bc1q...",
"nonce": "a1b2c3d4e5f6789012345678901234ab",
"expiresAt": 1747584000000,
"audience": "https://example.com",
"purpose": "login"
}
The address field is now cryptographically proven. Attach it to a signed session, issue a JWT, whatever your app requires.
Failure (401)
{ "ok": false, "reason": "sig_invalid" }
Failure reasons
| Reason | Meaning |
|---|---|
malformed | Message didn't parse as a challenge. |
expired | expires_at in the past (with skew). |
not_yet_valid | issued_at in the future (with skew). |
sig_invalid | Signature didn't verify. |
sig_unsupported_scheme | Scheme not supported for address type. |
nonce_mismatch | expectedNonce didn't match. |
audience_mismatch | expectedAudience didn't match. |
purpose_mismatch | expectedPurpose didn't match. |
End-to-end flow
// Server — GET /auth/challenge
import { issueChallenge } from '@orangecheck/sdk';
app.get('/auth/challenge', (req, res) => {
const c = issueChallenge({
address: req.query.addr,
audience: 'https://example.com',
purpose: 'login',
});
req.session.ocNonce = c.nonce; // defeat replay
res.json({ message: c.message });
});
// Client — user's wallet signs c.message with BIP-322
// Client — POSTs { message, signature } back
// Server — POST /auth/verify
import { verifyChallenge } from '@orangecheck/sdk';
app.post('/auth/verify', async (req, res) => {
const r = await verifyChallenge({
message: req.body.message,
signature: req.body.signature,
expectedNonce: req.session.ocNonce,
expectedAudience: 'https://example.com',
});
if (!r.ok) return res.status(401).json({ reason: r.reason });
req.session.verifiedAddress = r.address; // cryptographically proven
res.json({ ok: true });
});
For a full working demo, see Sign in with Bitcoin or the live /signin page.
Rate limit
30 req/min per IP (combined GET + POST).
Status codes
| HTTP | Meaning |
|---|---|
200 | GET: challenge issued. POST: signature verified. |
400 | Missing/malformed body. |
401 | POST only: verification failed. |
429 | Rate limited. |
500 | Server error. |