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:
- What ochk.io does — walk through the live flow at
/signinso you know what you're modeling. - 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 return — GET /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
/api/challengereference — the public stateless primitive./api/auth/*reference — ochk.io's own session endpoints.@orangecheck/wallet-adapter— wallet-agnostic signing.- Live demo