docs / sign in with bitcoin

Sign in with Bitcoin

A one-click, password-less sign-in where the user proves they control a Bitcoin address. No signup, no custody, no password reset flow — the wallet is the recovery mechanism.

This guide has two halves:

  1. What ochk.io does — walk through the live flow at /signin so you know what you're modeling.
  2. Build it in your own app — the generic challenge-response pattern with reference code for Node, Python, and a React client.

What ochk.io does

Signing in at /signin opens a session on ochk.io backed by /api/auth/*. The full mechanism:

1. User enters or auto-fills their Bitcoin address
        │
        ▼
2. Browser calls GET /api/challenge?addr=...&audience=...&purpose=ochk-signin
        │
        ▼   server returns { message, nonce, expiresAt }
3. Browser hands `message` to a wallet (UniSat / Xverse / Leather / paste)
        │
        ▼   wallet returns a BIP-322 signature
4. Browser POSTs /api/auth/signin { message, signature, expectedNonce }
        │
        ▼
5. Server verifies the signature, upserts the account keyed by btc_address,
   inserts a session row, and returns a Set-Cookie: oc_session=<JWT>.
        │
        ▼
6. Client redirects to /dashboard. The cookie is httpOnly, Secure,
   SameSite=Lax, 30-day Max-Age. Revoking = deleting the session row.

What you get in returnGET /api/auth/me returns:

{
  "ok": true,
  "account": {
    "id":                "uuid",
    "btc_address":       "bc1q...",
    "display_name":      null,
    "nostr_npub":        null,
    "created_at":        "...",
    "last_signed_in_at": "..."
  },
  "attestations": [ /* ... your proofs on this address ... */ ]
}

The dashboard at /dashboard lets you set a display name, link a Nostr npub, share your attestations' verification URLs, and sign out.

Error surface — the server returns specific reason codes (sig_invalid, expired, nonce_mismatch, …) and the signin UI maps them to actionable copy ("switch accounts in your wallet", "request a fresh challenge"). See the full reason table.

Self-hosting this exact stack — see the /api/auth/* reference. Three env vars and a Postgres DB, no Supabase lock-in.

Build it in your own app

If you're not trying to replicate ochk.io verbatim, here's the generic pattern. The two endpoint paths below (/auth/challenge, /auth/verify) are suggestions — you pick whatever names fit your app. They're your endpoints, not ochk.io's. (ochk.io's equivalents are /api/challenge and /api/auth/signin — totally distinct.)

The flow

┌──────────┐   1. GET  <your-server>/auth/challenge?addr=bc1q…   ┌─────────┐
│  Client  │ ──────────────────────────────────────────────────▶ │ Server  │
│          │                                                      │         │
│          │   ◀───────────────── { message, nonce }              │         │
│          │                                                      │         │
│          │   2. user signs message in their wallet              │         │
│          │                                                      │         │
│          │   3. POST <your-server>/auth/verify                  │         │
│          │        { message, signature }                        │         │
│          │ ──────────────────────────────────────────────────▶ │         │
│          │                                                      │  ← verifies
│          │                                                      │  ← stashes
│          │                                                      │    verified
│          │                                                      │    address
│          │                                                      │    in session
│  ◀─────────── { ok: true, address }                              │         │
└──────────┘                                                      └─────────┘

Server (Node) — implementation

import { issueChallenge, verifyChallenge } from '@orangecheck/sdk';
import express from 'express';
import session from 'express-session';

const app = express();
app.use(express.json());
app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false }));

declare module 'express-session' {
  interface SessionData {
    ocNonce?: string;
    verifiedAddress?: string;
  }
}

// 1. Issue
app.get('/auth/challenge', (req, res) => {
  const addr = String(req.query.addr);
  const c = issueChallenge({
    address: addr,
    ttlSeconds: 300,
    audience: 'https://example.com',
    purpose: 'login',
  });
  req.session.ocNonce = c.nonce;
  res.json({ message: c.message });
});

// 2. Verify — return specific reasons so the client can help the user recover.
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',
    expectedPurpose:  'login',
  });
  if (!r.ok) return res.status(401).json({ ok: false, reason: r.reason });
  req.session.verifiedAddress = r.address;   // cryptographically proven
  delete req.session.ocNonce;
  res.json({ ok: true, address: r.address });
});

Server (Python / Django) — implementation

from orangecheck import challenge_issue, challenge_verify, VerificationError
from django.http import JsonResponse
import json

def auth_challenge(request):
    addr = request.GET.get("addr")
    c = challenge_issue(addr=addr, audience="https://example.com", purpose="login")
    request.session["oc_nonce"] = c.nonce
    return JsonResponse({"message": c.message})

def auth_verify(request):
    body = json.loads(request.body)
    try:
        r = challenge_verify(
            message=body["message"],
            signature=body["signature"],
            expected_nonce=request.session.get("oc_nonce"),
            expected_audience="https://example.com",
            expected_purpose="login",
        )
    except VerificationError as e:
        return JsonResponse({"ok": False, "reason": str(e)}, status=401)
    request.session["verified_btc_address"] = r.address
    request.session.pop("oc_nonce", None)
    return JsonResponse({"ok": True, "address": r.address})

Client — in-browser

The client needs to (a) fetch the challenge, (b) ask a wallet to sign it, (c) POST the signature back. Ergonomic version:

import { OcWalletButton } from '@orangecheck/wallet-adapter/react';
import { useEffect, useState } from 'react';

export function SignIn({ address }: { address: string }) {
  const [message, setMessage] = useState<string | null>(null);
  const [error,   setError]   = useState<string | null>(null);
  const [proven,  setProven]  = useState<string | null>(null);

  useEffect(() => {
    fetch(`/auth/challenge?addr=${address}`)
      .then((r) => r.json())
      .then(({ message }) => setMessage(message));
  }, [address]);

  async function handleSigned(signature: string) {
    const r = await fetch('/auth/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message, signature }),
    });
    const body = await r.json();
    if (body.ok) setProven(body.address);
    else setError(body.reason);      // 'sig_invalid', 'expired', …
  }

  if (error)   return <p>Couldn't sign in: {error}</p>;
  if (proven)  return <p>Signed in as {proven}</p>;
  if (!message) return <p>Loading challenge…</p>;
  return <OcWalletButton address={address} message={message} onSigned={handleSigned} />;
}

Pro tip: before signing, call the wallet's requestAccounts() (UniSat / Leather) or getAddresses() (Xverse) and compare to the address you issued the challenge for. A "wrong active account" is the #1 cause of sig_invalid in production.

Manually (no React)

const { message } = await fetch(`/auth/challenge?addr=${address}`).then((r) => r.json());
const signature   = await window.unisat.signMessage(message, 'bip322-simple');
const result      = await fetch('/auth/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message, signature }),
}).then((r) => r.json());

if (result.ok) {
  // Proven address is now in the server's session.
}

Putting it behind a gate

Once the session has a verified address, any later gate just reads from it:

import { ocGate } from '@orangecheck/gate';

app.post('/post', ocGate({
  minSats: 100_000,
  minDays: 30,
  address: { from: (req) => (req as any).session.verifiedAddress },
}), handler);

Why a challenge-response, not just a header

Anyone can put any Bitcoin address in an X-OC-Address header. An OrangeCheck attestation proves that address has stake — but NOT that the current requester is the address holder.

The challenge-response flow closes that loop: the server mints a fresh nonce, the user signs it, and the signature binds the session to the address.

For low-stakes gates (public forum posting), trusting the header is fine. For high-stakes gates (airdrops, payments, one-vote-per-human), always verify address control first.

Further