docs / /api/auth/*

/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/*
PurposeGeneric BIP-322 challenge/verifyochk.io's own sign-in
StateStateless (caller tracks nonce)Server creates a session row
Response{ ok, address }Sets oc_session cookie
Account modelNoneUpserts an account row keyed by address
Error detailGeneric invalid_challengeSpecific reasons, UX-friendly
CORS*same-origin only
Auth requiredNoYes, for me / logout / account

Endpoint map

EndpointMethodPurpose
/api/auth/signinPOSTExchange a signed challenge for a session cookie.
/api/auth/meGETReturn the signed-in account + cached attestations.
/api/auth/logoutPOSTRevoke the session and clear the cookie.
/api/auth/accountPATCHUpdate 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 sessions table keyed by SHA-256(token). Deleting the row revokes the session even before expiry.
  • Secure in production, dropped in dev so http://localhost still 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 requires Content-Type: application/json and 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"
}
FieldRequired?Notes
messageyesThe canonical challenge text, verbatim.
signatureyesBIP-322 or legacy. Base64 or hex.
expectedNonceoptional but recommended32-lowercase-hex; defeats replay if the client stashed the nonce at issue time.
schemeoptionalbip322 (default) or legacy.
expectedAudienceoptionalIf set, the challenge's audience: extension must match.
expectedPurposeoptionalIf 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" }
reasonMeaningTypical fix
sig_invalidSignature didn't match the address on the challenge.Wallet is on a different account. Switch accounts or use the wallet's connected address.
expiredChallenge TTL elapsed before we got your signature.Request a fresh challenge (default 5-minute TTL).
not_yet_validClock skew — client is ahead of server's issued_at.Check the client's clock.
nonce_mismatchexpectedNonce didn't match the nonce baked into the message.Reset flow; don't reuse a challenge across tabs.
audience_mismatchexpectedAudience didn't match.Request the challenge from the same origin you're verifying on.
purpose_mismatchexpectedPurpose didn't match.Issue the challenge with the same purpose you verify with.
sig_unsupported_schemeScheme doesn't apply (e.g. legacy on a non-P2PKH address).Switch scheme or wallet.
malformedMessage isn't a valid orangecheck-auth challenge.Re-issue.

Error reasons — other

StatusBodyMeaning
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_session cookie 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..."
}
FieldTypeNotes
display_namestring | nullUp to 120 chars. null clears.
nostr_npubstring | nullMust 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

StatusBodyMeaning
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