Guides
Capabilities
The three BSV building blocks create-bsv-app wires into your app, the APIs they give you, and the one proof mechanism behind all of them.
A capability is a small set of readable TypeScript files dropped into src/bsv/, plus the dependencies, routes and providers they need. Pick them with --capabilities a,b or in the prompts.
| Capability | Requires | Client | Server |
|---|---|---|---|
wallet-connect (always on) |
nothing | useWallet() React context<ConnectWallet /> button and fallback dialogapiClient.ts: bounded, redirect-free fetchgetServerIdentity() |
GET /api/identityWalletRelayService: /api/session and /ws for mobile QR pairing |
wallet-login |
wallet-connect |
/login pageuseWalletLogin() hook |
POST /api/login via loginRoute(serverWallet) |
signed-requests |
wallet-connect |
/signed-demo pageuseSignedRequest() and signedFetch() |
POST /api/echoverifySignedRequest(), framework-agnostic |
Dependencies are resolved for you: asking for wallet-login pulls in wallet-connect.
wallet-connect#
Connect any BRC-100 wallet and use it anywhere in your React tree. It's always installed in new projects.
import { useWallet } from './bsv/WalletContext'
const { status, connected, wallet, identityKey, connect, connectMobile, cancel } = useWallet()| Field | Type | What it is |
|---|---|---|
status |
'disconnected' | 'connecting' | 'choosing' | 'pairing' | 'connected' |
where the connect flow is |
connected |
boolean |
wallet !== null |
wallet |
WalletInterface | null |
the full BRC-100 wallet from @bsv/sdk |
identityKey |
string | null |
the user's public identity key (hex) |
connect() |
() => Promise<void> |
try the desktop wallet; on failure go to choosing |
connectMobile() |
() => Promise<void> |
start QR pairing (pairing) |
cancel() |
() => void |
back to disconnected |
The flow is a small state machine:
disconnected ──connect()──▶ connecting ──desktop wallet found──▶ connected
│
└─ none found ─▶ choosing ──connectMobile()──▶ pairing ──QR scanned──▶ connected<ConnectWallet /> renders all of this for you: the button, the No desktop wallet found dialog and the QR code. Use it as is, restyle it, or build your own UI on useWallet().
wallet-login#
Passwordless login. The wallet signs a proof with action: 'login', the server verifies it, and you get an identityKey you can trust.
import { useWalletLogin } from './bsv/useWalletLogin'
const { login } = useWalletLogin()
const { identityKey } = await login()import { loginRoute } from './bsv/loginRoute.js'
app.post('/api/login', loginRoute(serverWallet)) // → { identityKey } or 401useWalletLogin() takes optional { serverIdentityKey, loginEndpoint }. Pass serverIdentityKey to pin the server's key instead of fetching it. Here's why you might.
Turning login into a session#
The scaffold stops at "this identity key is proven". It doesn't pick a session strategy for you. Here's a pattern we've tested end to end: exchange the login proof for a short-lived JWT, then send it as a bearer header.
// Turn a verified wallet login into a short-lived bearer token.
import type { NextFunction, interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = QueryString.ParsedQs, Locals extends Record<string, any> = Record<string, any>>Request, interface Response<ResBody = any, Locals extends Record<string, any> = Record<string, any>>Response } from 'express'
import { class SignJWTSignJWT, function jwtVerify<PayloadType = JWTPayload>(jwt: string | Uint8Array, key: KeyInput, options?: JWTVerifyOptions): Promise<JWTVerifyResult<PayloadType>> (+2 overloads)jwtVerify } from 'jose'
import { function verifyAuthProof(serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}, proof: AuthProof, opts: {
action: string;
body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
valid: boolean;
identityKey?: string;
error?: string;
}>
verifyAuthProof } from './bsv/auth.js'
import { function consumeNonce(nonce: string, expiresAt: Date): booleanconsumeNonce } from './bsv/nonceStore.js'
type type ServerWallet = {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}
ServerWallet = type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : neverParameters<typeof function verifyAuthProof(serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}, proof: AuthProof, opts: {
action: string;
body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
valid: boolean;
identityKey?: string;
error?: string;
}>
verifyAuthProof>[0]
const const secretText: string | undefinedsecretText = var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvenv.string | undefinedJWT_SECRET
if (const secretText: string | undefinedsecretText == null || new var TextEncoder: new () => TextEncoderTextEncoder().TextEncoder.encode(input?: string): Uint8Array<ArrayBuffer>encode(const secretText: stringsecretText).Uint8Array<ArrayBuffer>.byteLength: numberbyteLength < 32) {
throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error('JWT_SECRET must contain at least 32 bytes')
}
const const secret: Uint8Array<ArrayBuffer>secret = new var TextEncoder: new () => TextEncoderTextEncoder().TextEncoder.encode(input?: string): Uint8Array<ArrayBuffer>encode(const secretText: stringsecretText)
/** POST /api/session-login: verify a `login` proof, return { token, identityKey }. */
export function function sessionLogin(serverWallet: ServerWallet): (req: Request, res: Response) => Promise<void>sessionLogin (serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}
serverWallet: type ServerWallet = {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}
ServerWallet) {
return async (req: Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>req: interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = QueryString.ParsedQs, Locals extends Record<string, any> = Record<string, any>>Request, res: Response<any, Record<string, any>>res: interface Response<ResBody = any, Locals extends Record<string, any> = Record<string, any>>Response): interface Promise<T>Promise<void> => {
const const result: {
valid: boolean;
identityKey?: string;
error?: string;
}
result = await function verifyAuthProof(serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}, proof: AuthProof, opts: {
action: string;
body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
valid: boolean;
identityKey?: string;
error?: string;
}>
verifyAuthProof(serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}
serverWallet, req: Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>req.Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>.body: anybody, { action: stringaction: 'login' }, function consumeNonce(nonce: string, expiresAt: Date): booleanconsumeNonce)
if (!const result: {
valid: boolean;
identityKey?: string;
error?: string;
}
result.valid: booleanvalid || const result: {
valid: boolean;
identityKey?: string;
error?: string;
}
result.identityKey?: string | undefinedidentityKey == null) {
res: Response<any, Record<string, any>>res.Response<any, Record<string, any>, number>.status(code: number): Response<any, Record<string, any>>status(401).Response<any, Record<string, any>, number>.json: (body?: any) => Response<any, Record<string, any>>json({ error: stringerror: 'invalid proof' })
return
}
const const token: stringtoken = await new new SignJWT(payload?: JWTPayload): SignJWTSignJWT({ JWTPayload.sub?: string | undefinedsub: const result: {
valid: boolean;
identityKey?: string;
error?: string;
}
result.identityKey?: stringidentityKey })
.SignJWT.setProtectedHeader(protectedHeader: JWTHeaderParameters): SignJWTsetProtectedHeader({ CompactJWSHeaderParameters.alg: JWSAlgorithmalg: 'HS256' })
.ProduceJWT.setIssuedAt(input?: number | string | Date): SignJWTsetIssuedAt()
.ProduceJWT.setExpirationTime(input: number | string | Date): SignJWTsetExpirationTime('1h')
.SignJWT.sign(key: KeyInput, options?: SignOptions): Promise<string>sign(const secret: Uint8Array<ArrayBuffer>secret)
res: Response<any, Record<string, any>>res.Response<any, Record<string, any>, number>.json: (body?: any) => Response<any, Record<string, any>>json({ token: stringtoken, identityKey: stringidentityKey: const result: {
valid: boolean;
identityKey?: string;
error?: string;
}
result.identityKey?: stringidentityKey })
}
}
/** Middleware: require `Authorization: Bearer <token>`; exposes res.locals.identityKey. */
export async function function requireSession(req: Request, res: Response, next: NextFunction): Promise<void>requireSession (req: Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>req: interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = QueryString.ParsedQs, Locals extends Record<string, any> = Record<string, any>>Request, res: Response<any, Record<string, any>>res: interface Response<ResBody = any, Locals extends Record<string, any> = Record<string, any>>Response, next: NextFunctionnext: NextFunction): interface Promise<T>Promise<void> {
const const token: string | undefinedtoken = req: Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>req.Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>.get(name: string): string | undefined (+1 overload)get('authorization')?.String.replace(searchValue: string | RegExp, replaceValue: string): string (+3 overloads)replace(/^Bearer /, '')
try {
const { const payload: JWTPayloadpayload } = await jwtVerify<JWTPayload>(jwt: string | Uint8Array, key: KeyInput, options?: JWTVerifyOptions): Promise<JWTVerifyResult<JWTPayload>> (+2 overloads)jwtVerify(const token: string | undefinedtoken ?? '', const secret: Uint8Array<ArrayBuffer>secret, { VerifyOptions.algorithms?: JWSAlgorithm[] | undefinedalgorithms: ['HS256'] })
res: Response<any, Record<string, any>>res.Response<any, Record<string, any>, number>.locals: Record<string, any> & Localslocals.identityKey = const payload: JWTPayloadpayload.JWTPayload.sub?: string | undefinedsub
next: NextFunction
(err?: any) => void (+2 overloads)
next()
} catch {
res: Response<any, Record<string, any>>res.Response<any, Record<string, any>, number>.status(code: number): Response<any, Record<string, any>>status(401).Response<any, Record<string, any>, number>.json: (body?: any) => Response<any, Record<string, any>>json({ error: stringerror: 'not logged in' })
}
}import { requireSession, sessionLogin } from './session.js'
app.post('/api/session-login', sessionLogin(serverWallet))
app.get('/api/me', requireSession, (_req, res) => {
res.json({ identityKey: res.locals.identityKey })
})// Exchange a wallet login proof for a bearer token, then call protected routes.
import type { WalletInterface } from '@bsv/sdk'
import { function createAuthProof(wallet: ProofSignerWallet, opts: {
counterparty: string;
action: string;
body?: RequestBody;
}): Promise<AuthProof>
createAuthProof } from './bsv/auth'
import { function getServerIdentity(endpoint?: string): Promise<string>getServerIdentity } from './bsv/serverIdentity'
import { function apiFetch(path: string, init?: RequestInit): Promise<Response>apiFetch, function readApiJson(response: Response): Promise<unknown>readApiJson } from './bsv/apiClient'
let let token: string | nulltoken: string | null = null
export async function function loginForSession(wallet: WalletInterface): Promise<void>loginForSession (wallet: WalletInterfacewallet: WalletInterface): interface Promise<T>Promise<void> {
const const counterparty: stringcounterparty = await function getServerIdentity(endpoint?: string): Promise<string>getServerIdentity()
const const proof: AuthProofproof = await function createAuthProof(wallet: ProofSignerWallet, opts: {
counterparty: string;
action: string;
body?: RequestBody;
}): Promise<AuthProof>
createAuthProof(wallet: WalletInterfacewallet, { counterparty: stringcounterparty, action: stringaction: 'login' })
const const res: Responseres = await function apiFetch(path: string, init?: RequestInit): Promise<Response>apiFetch('/api/session-login', {
RequestInit.method?: string | undefinedmethod: 'POST',
RequestInit.headers?: HeadersInit | undefinedheaders: { 'content-type': 'application/json' },
RequestInit.body?: BodyInit | null | undefinedbody: var JSON: JSONJSON.JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)stringify(const proof: AuthProofproof)
})
if (!const res: Responseres.Response.ok: booleanok) throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error(`login failed: ${const res: Responseres.Response.status: numberstatus}`)
let token: string | nulltoken = (await function readApiJson(response: Response): Promise<unknown>readApiJson(const res: Responseres) as { token: stringtoken: string }).token: stringtoken
}
export async function function authedFetch(path: string, init?: RequestInit): Promise<Response>authedFetch (path: stringpath: string, init: RequestInitinit: RequestInit = {}): interface Promise<T>Promise<Response> {
if (let token: string | nulltoken == null) throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error('log in first')
return await function apiFetch(path: string, init?: RequestInit): Promise<Response>apiFetch(path: stringpath, { ...init: RequestInitinit, RequestInit.headers?: HeadersInit | undefinedheaders: { ...init: RequestInitinit.RequestInit.headers?: HeadersInit | undefinedheaders, authorization: stringauthorization: `Bearer ${let token: stringtoken}` } })
}Install jose in the server (npm i jose), and set JWT_SECRET to 32+ random bytes. node -e "console.log(crypto.randomBytes(32).toString('base64url'))" makes one.
signed-requests#
Authenticate a single API call, with no session at all. The proof is bound to an action name and the exact request body.
import { useSignedRequest } from './bsv/useSignedRequest'
const { signedFetch } = useSignedRequest()
const res = await signedFetch('/api/notes', { action: 'create-note', body: { text: 'gm' } })import { verifySignedRequest } from './bsv/verifySignedRequest.js'
import { consumeNonce } from './bsv/nonceStore.js'
app.post('/api/notes', async (req, res) => {
const { proof, body } = req.body
const r = await verifySignedRequest(serverWallet, proof, { action: 'create-note', body }, consumeNonce)
if (!r.valid) { res.status(401).json({ error: 'invalid proof' }); return }
// r.identityKey is the signer. Authorize them, then do the work.
res.json({ ok: true, by: r.identityKey })
})signedFetch(path, { action, body }) always sends POST with { proof, body } as JSON. verifySignedRequest is a plain function, so it works the same in Express, Fastify, Hono or a Next.js route handler.
When to use which? Use login plus a session for an app people stay in. Use signed requests for individual high-value actions (posting, paying, voting, admin operations) and for machine-to-machine calls where there's no browser to hold a session.
How the proof works#
All three capabilities share one primitive from @bsv/auth (opens in a new tab) (BRC-103 style mutual authentication, simplified to a single message):
- The client fetches the server's public identity key from
GET /api/identity. It becomes the proof's counterparty. - The wallet signs
{ action, identityKey, expiresAt, nonce }, with the request body's exact JSON bytes appended when there is one. The signing key is derived between the user's identity and the server's, so a proof made for one server can't be replayed against another. - The server checks the signature, checks
expiresAt(proofs live 2 minutes by default, with 30 seconds allowed for clock skew), and callsconsumeNonce, which refuses any nonce it has already seen. - If all three checks pass,
identityKeyis cryptographically proven. If any check fails, you get{ valid: false }.
Deep diveWhy "exact JSON bytes" matters
The client signs JSON.stringify(body), and the server verifies JSON.stringify of the body it parsed. These match as long as nothing rewrites the object in between. Express's JSON parser keeps key order, so it just works. If you add middleware that normalizes, sorts or coerces request bodies, run it after verification, not before.
Adding a capability later#
npx create-bsv-app@latest add --capabilities signed-requests --yesRun this inside the project. The new files are placed, dependencies are added and installed, and bsv-scaffold.json is updated. Your own files aren't rewritten. More on add mode.