# Tutorial: a signed guestbook

> Build a real feature on top of your scaffold. Every guestbook entry is signed by a wallet and verified by your server, with no accounts or passwords.

> Agents: search these docs with the `search_docs` tool on the MCP server at https://createbsvapp.vercel.app/mcp, or read everything at https://createbsvapp.vercel.app/llms-full.txt.

You'll add one API route, one React page and one test script. By the end, your server will know *exactly* who wrote each entry, cryptographically, without storing a single password.

**You'll learn how to:**

- protect an Express route with `verifySignedRequest()`
- call it from React with `signedFetch()`
- prove from the terminal that replayed and tampered requests are rejected

Allow about 15 minutes. The code on this page was compiled and run against create-bsv-app 1.1.2.

::: tip New to signed requests? Try them in your browser first
The [interactive tutorial](https://createbsvapp.vercel.app/learn/welcome) runs the scaffold's real code in the page, with a tutorial wallet: log in, send a signed request, then replay it and watch the server refuse. Nine short lessons, nothing to install. This page then builds the same ideas into your own project.
:::

## What you're building

```text [the flow]
Browser                       Wallet                      Your server
───────                       ──────                      ───────────
type "gm", click Sign  ──▶  signs { action, body }
                            with your identity key  ──▶  POST /api/guestbook { proof, body }
                                                          verifySignedRequest()
                                                            ✓ signature valid for this body
                                                            ✓ nonce never seen before
                                                            ✓ proof not expired
◀───────────────────────────────────────────────────────  201 { identityKey, message }
```

The server never sees a password or a session cookie. It gets a signature it can check, and the identity key that made it.

## 1. Scaffold the app

Skip this step if you already followed the [quick start](https://createbsvapp.vercel.app/docs). Otherwise:

::: code-group
```bash [npm]
npx create-bsv-app@latest guestbook --starter full-stack --capabilities signed-requests --yes
cd guestbook
```
```bash [pnpm]
pnpm create bsv-app guestbook --starter full-stack --capabilities signed-requests --package-manager pnpm --yes
cd guestbook
```
```bash [yarn]
yarn create bsv-app guestbook --starter full-stack --capabilities signed-requests --package-manager yarn --yes
cd guestbook
```
```bash [bun]
bun create bsv-app guestbook --starter full-stack --capabilities signed-requests --package-manager bun --yes
cd guestbook
```
:::

Run `npm run dev` and keep it running. The server restarts itself when you save, and the client hot-reloads.

## 2. Add the server route

Create a new file next to the server entry. Anyone can read the guestbook. Writing needs a valid signed request.

::: code-group
```ts [server/src/guestbook.ts] twoslash
// A tiny guestbook: anyone can read, only a wallet-signed request can write.
import type { Request, Response } from 'express'
import { verifySignedRequest } from './bsv/verifySignedRequest.js'
import { consumeNonce } from './bsv/nonceStore.js'

type ServerWallet = Parameters<typeof verifySignedRequest>[0]
interface Entry { identityKey: string, message: string, at: string }

const entries: Entry[] = [] // in memory for now; swap for a database later

export function listEntries (_req: Request, res: Response): void {
  res.json(entries.slice(-50).reverse())
}

export function signEntry (serverWallet: ServerWallet) {
  return async (req: Request, res: Response): Promise<void> => {
    const { proof, body } = req.body ?? {}
    const message = typeof body?.message === 'string' ? body.message.trim() : ''
    if (message.length === 0 || message.length > 280) {
      res.status(400).json({ error: 'message must be 1–280 characters' })
      return
    }
    // The proof is bound to this exact action + body. Change either and it fails.
    const result = await verifySignedRequest(serverWallet, proof, { action: 'sign-guestbook', body }, consumeNonce)
    if (!result.valid || result.identityKey == null) {
      res.status(401).json({ error: 'invalid proof' })
      return
    }
    const entry = { identityKey: result.identityKey, message, at: new Date().toISOString() }
    entries.push(entry)
    res.status(201).json(entry)
  }
}
```
```ts [bsv/verifySignedRequest.ts (generated)]
// Framework-agnostic verification of a signed request. Works in Express, Next API
// routes, Fastify — it's a plain function. Pass your own single-use nonce store.
import { verifyAuthProof, type AuthProof, type RequestBody } from './auth.js'

export async function verifySignedRequest (
  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 }> {
  return await verifyAuthProof(serverWallet, proof, { action: opts.action, body: opts.body }, consumeNonce)
}
```
:::

Then mount both handlers in `server/src/index.ts`, next to the existing `/api/echo` route:

```ts [server/src/index.ts]
import { consumeNonce } from './bsv/nonceStore.js'
import { listEntries, signEntry } from './guestbook.js' // ← add this line

// …

app.post('/api/echo', async (req, res) => { /* … */ })
app.get('/api/guestbook', listEntries) // ← add this line
app.post('/api/guestbook', signEntry(serverWallet)) // ← add this line
```

::: deep Why `.js` in a TypeScript import?
The server compiles to Node ES modules (`"module": "NodeNext"`), and Node needs the real file extension at runtime. TypeScript resolves `./guestbook.js` to `guestbook.ts` while type-checking. The generated files follow the same rule.
:::

## 3. Build the page

`useSignedRequest()` gives you `signedFetch()`. It fetches the server's identity, asks the wallet to sign `{ action, body }`, and posts `{ proof, body }` through the bounded API client.

::: code-group
```tsx [client/src/Guestbook.tsx] twoslash
import { useEffect, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { ConnectWallet } from './bsv/ConnectWallet'
import { useSignedRequest } from './bsv/useSignedRequest'
import { apiFetch, readApiJson } from './bsv/apiClient'

interface Entry { identityKey: string, message: string, at: string }

async function fetchEntries (): Promise<Entry[]> {
  const res = await apiFetch('/api/guestbook')
  return res.ok ? await readApiJson(res) as Entry[] : []
}

export function Guestbook () {
  const { signedFetch, connected } = useSignedRequest()
  const [entries, setEntries] = useState<Entry[]>([])
  const [message, setMessage] = useState('')
  const [status, setStatus] = useState<string | null>(null)

  useEffect(() => {
    let live = true
    fetchEntries().then((list) => { if (live) setEntries(list) }).catch(() => {})
    return () => { live = false }
  }, [])

  const sign = async (e: FormEvent) => {
    e.preventDefault()
    setStatus('Approve the request in your wallet…')
    try {
      const res = await signedFetch('/api/guestbook', { action: 'sign-guestbook', body: { message } })
      if (!res.ok) { setStatus(`Server rejected it (${res.status})`); return }
      setMessage('')
      setStatus(null)
      setEntries(await fetchEntries())
    } catch (err) {
      setStatus(String(err))
    }
  }

  return (
    <main className="bsv-page">
      <Link className="bsv-back" to="/">← Back to home</Link>
      <h1>Guestbook</h1>
      <p>Sign with your wallet. No account, no password.</p>
      <ConnectWallet />
      {connected && (
        <form onSubmit={(e) => { void sign(e) }}>
          <input value={message} onChange={(e) => setMessage(e.target.value)} maxLength={280} placeholder="Say gm" />
          <button className="bsv-btn" disabled={message.trim() === ''}>Sign guestbook</button>
        </form>
      )}
      {status != null && <p>{status}</p>}
      <ul>
        {entries.map((entry) => (
          <li key={entry.at + entry.identityKey}>
            <code>{entry.identityKey.slice(0, 10)}…</code> {entry.message}
          </li>
        ))}
      </ul>
    </main>
  )
}
```
```ts [bsv/useSignedRequest.ts (generated)]
// Hook: signedFetch attaches a proof bound to the route + JSON body.
import { useCallback } from 'react'
import { useWallet } from './WalletContext.js'
import { createSignedRequest } from './signedRequest.js'
import { getServerIdentity, requireIdentityKey } from './serverIdentity.js'
import { apiFetch } from './apiClient.js'
import type { RequestBody } from './auth.js'

// serverIdentityKey is optional: when omitted it's fetched from GET /api/identity.
export function useSignedRequest (serverIdentityKey?: string) {
  const { wallet } = useWallet()
  const signedFetch = useCallback(async (url: string, opts: { action: string, body?: RequestBody }): Promise<Response> => {
    if (wallet === null) throw new Error('connect a wallet first')
    const counterparty = requireIdentityKey(serverIdentityKey ?? await getServerIdentity())
    const proof = await createSignedRequest(wallet, { serverIdentityKey: counterparty, action: opts.action, body: opts.body })
    return await apiFetch(url, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ proof, body: opts.body })
    })
  }, [wallet, serverIdentityKey])
  return { signedFetch, connected: wallet !== null }
}
```
:::

Register the route and link to it from the home hub:

::: code-group
```tsx [client/src/App.tsx]
import { SignedRequestDemo } from './bsv/SignedRequestDemo'
import { Guestbook } from './Guestbook' // ← add this line

// …inside <Routes>
        <Route path="/signed-demo" element={<SignedRequestDemo />} />
        <Route path="/guestbook" element={<Guestbook />} /> {/* ← add this line */}
```
```tsx [client/src/bsv/Home.tsx]
          <Link to="/signed-demo">Signed request demo →</Link>
          <Link to="/guestbook">Guestbook →</Link> {/* ← add this line */}
```
:::

::: warning `action` and `body` must match on both sides
The client signs `action: 'sign-guestbook'` with `{ message }`. The server must verify the same action and the same body object it received. Rename one side only and every request comes back `401`.
:::

## 4. Try it

Open **http://localhost:5173**, connect your wallet, then click **Guestbook →**. Type a message and press **Sign guestbook**. Your wallet asks you to approve, and then your entry appears with the first characters of your identity key.

Open a second browser profile with a different wallet and sign again. Two keys, two authors, and no user table anywhere.

## 5. Break it on purpose

A guestbook is only as good as its forgery protection, so let's attack it. This script signs entries with a brand-new throwaway key, then tries a **replay** (sending the same proof twice) and a **tamper** (a valid proof with a different body):

```ts [server/scripts/try-guestbook.ts] twoslash
// @filename: scripts/try-guestbook.ts
// ---cut---
// Sign guestbook entries from Node with a throwaway key, then try to cheat.
import { PrivateKey, ProtoWallet } from '@bsv/sdk'
import { createAuthProof } from '../src/bsv/auth.js'

const API = 'http://localhost:3000'
const { identityKey: server } = await (await fetch(`${API}/api/identity`)).json()
const wallet = new ProtoWallet(PrivateKey.fromRandom()) // a brand-new identity

async function post (label: string, payload: unknown) {
  const res = await fetch(`${API}/api/guestbook`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(payload)
  })
  console.log(label.padEnd(10), res.status, await res.text())
}

const body = { message: 'gm from Node' }
const proof = await createAuthProof(wallet, { counterparty: server, action: 'sign-guestbook', body })

await post('signed', { proof, body })
await post('replayed', { proof, body })
await post('tampered', { proof: await createAuthProof(wallet, { counterparty: server, action: 'sign-guestbook', body }), body: { message: 'gm from Mallory' } })
```

Run it from the `server` folder while `npm run dev` is still going. You should see:

```console
$ cd server
$ npx tsx scripts/try-guestbook.ts
signed     201 {"identityKey":"020d26…","message":"gm from Node","at":"…"}
replayed   401 {"error":"invalid proof"}
tampered   401 {"error":"invalid proof"}
```

The replay fails because the server already consumed that proof's nonce. The tamper fails because the signature covers the exact body, and `gm from Mallory` isn't what was signed.

::: deep What exactly is in a proof?
The wallet signs `{ action, identityKey, expiresAt, nonce }` with the body's exact JSON bytes appended, using a key derived between *your* identity key and the *server's* identity key (that's why the client fetches `GET /api/identity` first). The server checks the signature, checks that `expiresAt` hasn't passed (proofs live for 2 minutes by default), and records the nonce so it can never be used again. See [Security model](https://createbsvapp.vercel.app/docs/security) for the full list of guarantees.
:::

## Recap

You built a feature where:

- **identity comes from the wallet.** `result.identityKey` is the only "user id" you need.
- **every write is authenticated on its own.** No session to steal, no cookie to fixate.
- **forgery fails closed.** Replays, tampering and expired proofs all get `401`.

## Where to take it next

- **Persist it.** Swap the `entries` array for a database, keyed by `identityKey`.
- **Harden replay protection.** The generated `nonceStore.ts` is in-memory and single-process. Before you run more than one server instance, back it with Redis or your database. [Here's how](https://createbsvapp.vercel.app/docs/security#replay-protection-across-instances).
- **Add sessions if you want them.** Use [wallet login](https://createbsvapp.vercel.app/docs/capabilities#wallet-login) once, then issue your own session or JWT.
- **Ship it.** [Deploy](https://createbsvapp.vercel.app/docs/deploy) the client and server, with the three environment variables production needs.
