/api/auth/*
The site-internal session layer. These endpoints back /signin and /dashboard on ochk.io — they turn a BIP-322 signature into an httpOnly session cookie and let the signed-in user read and patch their account.
For the public signed-challenge primitive (issue + verify a message, no cookie, no account), see /api/challenge. If you're building your own app and want the same flow, skip to the Sign in with Bitcoin guide.
Differences from /api/challenge
/api/challenge | /api/auth/* | |
|---|---|---|
| Purpose | Generic BIP-322 challenge/verify | ochk.io's own sign-in |
| State | Stateless (caller tracks nonce) | Server creates a session row |
| Response | { ok, address } | Sets oc_session cookie |
| Account model | None | Upserts an account row keyed by address |
| Error detail | Generic invalid_challenge | Specific reasons, UX-friendly |
| CORS | * | same-origin only |
| Auth required | No | Yes, for me / logout / account |
Endpoint map
| Endpoint | Method | Purpose |
|---|---|---|
/api/auth/signin | POST | Exchange a signed challenge for a session cookie. |
/api/auth/me | GET | Return the signed-in account + cached attestations. |
/api/auth/logout | POST | Revoke the session and clear the cookie. |
/api/auth/account | PATCH | Update profile fields on the signed-in account. |
Every response carries Cache-Control: no-store.
Session cookie
On successful POST /api/auth/signin we set a single cookie:
oc_session=<jwt>; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=2592000
- HS256 JWT signed with
SESSION_SECRET(server env var). - Max-Age — 30 days.
- Revocation list — every cookie has a row in the
sessionstable keyed bySHA-256(token). Deleting the row revokes the session even before expiry. - Secure in production, dropped in dev so
http://localhoststill works. - SameSite=Lax — the cookie rides cross-site GETs but not cross-site POSTs. No CSRF protection is layered on top because every mutating endpoint (
signin,logout,account) also requiresContent-Type: application/jsonand same-origin.
POST /api/auth/signin
Complete a signed-challenge flow and open a session.
Request
POST /api/auth/signin
Content-Type: application/json
{
"message": "orangecheck-auth\naddress: bc1q...\nnonce: ...",
"signature": "<base64 or hex>",
"expectedNonce":"a1b2c3d4e5f6789012345678901234ab",
"scheme": "bip322",
"expectedAudience": "https://ochk.io",
"expectedPurpose": "ochk-signin"
}
| Field | Required? | Notes |
|---|---|---|
message | yes | The canonical challenge text, verbatim. |
signature | yes | BIP-322 or legacy. Base64 or hex. |
expectedNonce | optional but recommended | 32-lowercase-hex; defeats replay if the client stashed the nonce at issue time. |
scheme | optional | bip322 (default) or legacy. |
expectedAudience | optional | If set, the challenge's audience: extension must match. |
expectedPurpose | optional | If set, the challenge's purpose: must match. |
Response — 200 OK
HTTP/1.1 200 OK
Set-Cookie: oc_session=...
Content-Type: application/json
{
"ok": true,
"account": {
"id": "2f1a...-UUID",
"btc_address": "bc1q...",
"display_name": null,
"nostr_npub": null,
"created_at": "2026-04-22T12:00:00Z",
"last_signed_in_at": "2026-04-22T12:00:00Z"
}
}
Error reasons — 401
{ "ok": false, "reason": "sig_invalid" }
reason | Meaning | Typical fix |
|---|---|---|
sig_invalid | Signature didn't match the address on the challenge. | Wallet is on a different account. Switch accounts or use the wallet's connected address. |
expired | Challenge TTL elapsed before we got your signature. | Request a fresh challenge (default 5-minute TTL). |
not_yet_valid | Clock skew — client is ahead of server's issued_at. | Check the client's clock. |
nonce_mismatch | expectedNonce didn't match the nonce baked into the message. | Reset flow; don't reuse a challenge across tabs. |
audience_mismatch | expectedAudience didn't match. | Request the challenge from the same origin you're verifying on. |
purpose_mismatch | expectedPurpose didn't match. | Issue the challenge with the same purpose you verify with. |
sig_unsupported_scheme | Scheme doesn't apply (e.g. legacy on a non-P2PKH address). | Switch scheme or wallet. |
malformed | Message isn't a valid orangecheck-auth challenge. | Re-issue. |
Error reasons — other
| Status | Body | Meaning |
|---|---|---|
400 | { error: "bad_request", issues: [...] } | Zod validation failed. |
405 | { error: "method_not_allowed" } | Only POST is allowed. |
429 | { error: "rate_limited" } | 20 req/min/IP. |
500 | { error: "server_error" } | Look at server logs. |
GET /api/auth/me
Read the signed-in account and its cached attestations. Requires the oc_session cookie.
Request
GET /api/auth/me
Cookie: oc_session=...
Response — 200 OK
{
"ok": true,
"account": {
"id": "...",
"btc_address": "bc1q...",
"display_name": "satoshi",
"nostr_npub": "npub1...",
"created_at": "2026-04-22T12:00:00Z",
"last_signed_in_at": "2026-04-22T12:00:00Z"
},
"attestations": [
{
"attestation_id": "atst_...",
"btc_address": "bc1q...",
"scheme": "bip322",
"identities": [{ "protocol": "nostr", "identifier": "npub1..." }],
"created_at": "2026-04-20T...",
"last_verified_at": "2026-04-22T...",
"sats_bonded": 100000,
"days_unspent": 90,
"score": 42,
"verification_url": "https://ochk.io/verify/atst_...",
"published_to_nostr": true
}
]
}
Response — 401
{ "ok": false, "reason": "not_authenticated" }
Returned when:
- No
oc_sessioncookie is present. - Cookie is present but signature / expiry / revocation-list check fails.
- Cookie is valid but the underlying account row was deleted (
reason: "account_missing").
POST /api/auth/logout
Delete the server-side session row and clear the cookie. Idempotent — calling with no cookie or an already-revoked cookie still returns 200 and a cleared Set-Cookie header.
Request
POST /api/auth/logout
Cookie: oc_session=...
Response — 200 OK
Set-Cookie: oc_session=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0
{ "ok": true }
PATCH /api/auth/account
Update mutable profile fields on the signed-in account.
Request
PATCH /api/auth/account
Content-Type: application/json
Cookie: oc_session=...
{
"display_name": "satoshi",
"nostr_npub": "npub1xyz..."
}
| Field | Type | Notes |
|---|---|---|
display_name | string | null | Up to 120 chars. null clears. |
nostr_npub | string | null | Must match npub1[...]. null clears. |
Both fields are optional, but at least one must be present.
Response — 200 OK
{
"ok": true,
"account": {
"id": "...",
"btc_address": "bc1q...",
"display_name": "satoshi",
"nostr_npub": "npub1xyz...",
"created_at": "2026-04-22T12:00:00Z",
"last_signed_in_at": "2026-04-22T12:00:00Z"
}
}
Errors
| Status | Body | Meaning |
|---|---|---|
400 | { ok: false, reason: "bad_request", issues: [...] } | Zod validation failed (e.g. bad npub). |
400 | { ok: false, reason: "empty_patch" } | Neither field was provided. |
401 | { ok: false, reason: "not_authenticated" } | No valid session. |
Reference integration (TypeScript)
// 1. Ask the server for a challenge.
const q = new URLSearchParams({
addr: address,
audience: location.origin,
purpose: 'ochk-signin',
});
const { message, nonce } = await fetch(`/api/challenge?${q}`).then((r) => r.json());
// 2. Sign with the user's wallet (UniSat shown; see /docs/sdk/wallet-adapter for a
// wallet-agnostic helper).
const signature = await window.unisat.signMessage(message, 'bip322-simple');
// 3. Exchange the signature for a session cookie.
const r = await fetch('/api/auth/signin', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, signature, expectedNonce: nonce }),
});
const body = await r.json();
if (!body.ok) {
// body.reason is one of the specific error codes above.
throw new Error(body.reason);
}
// 4. Cookie is now set; /api/auth/me returns the account.
Self-hosting
If you're forking ochk.io, these endpoints require two server-only env vars:
SUPABASE_URL=https://<project-ref>.supabase.co
SUPABASE_SERVICE_ROLE_KEY=<service_role key, bypasses RLS>
SESSION_SECRET=<32+ random chars, used to sign JWTs>
The schema lives at src/lib/db/schema.sql — run it once against any Postgres 14+ database (not just Supabase).
See also
/api/challenge— the public stateless verify primitive.- Sign in with Bitcoin — build this same flow in your own app.
@orangecheck/wallet-adapter— wallet-agnostic signing helper.