docs / /api/challenge

/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

ParamRequired?TypeNotes
addryesstringBitcoin address to challenge.
audienceoptionalURLOrigin-binding. If set, verify will require match.
purposeoptionalstringLabel baked into the message (e.g. login, claim).
ttloptionalintSeconds, 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

FieldRequired?Notes
messageyesThe canonical challenge message as returned by GET.
signatureyesBIP-322 or legacy signature. Base64 or hex.
schemeoptional"bip322" (default) or "legacy".
expectedNonceoptional (recommended)The nonce you stashed at GET time.
expectedAudienceoptionalRequire the audience: extension to match.
expectedPurposeoptionalRequire 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

ReasonMeaning
malformedMessage didn't parse as a challenge.
expiredexpires_at in the past (with skew).
not_yet_validissued_at in the future (with skew).
sig_invalidSignature didn't verify.
sig_unsupported_schemeScheme not supported for address type.
nonce_mismatchexpectedNonce didn't match.
audience_mismatchexpectedAudience didn't match.
purpose_mismatchexpectedPurpose 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

HTTPMeaning
200GET: challenge issued. POST: signature verified.
400Missing/malformed body.
401POST only: verification failed.
429Rate limited.
500Server error.