> For the complete documentation index, see [llms.txt](https://docs.kumbaya.xyz/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.kumbaya.xyz/developers/building-agents/siwe.md).

# Sign in with Ethereum (SIWE)

Kumbaya's Client API at [`clients.kumbaya.xyz`](https://clients.kumbaya.xyz) supports [EIP-4361 (Sign-In With Ethereum)](https://eips.ethereum.org/EIPS/eip-4361) as a first-class authentication path. This is the right path for **agents, bots, and any integration with its own private key** - no Privy account, no social login.

A wallet that signs in via SIWE for the first time is **automatically registered** as a user with a synthetic `privyDid: "wallet:<address>"`. From there it has the same JWT-authenticated access as any Privy-backed account.

## Endpoints

| Endpoint                    | Method | Auth | Purpose                                              |
| --------------------------- | ------ | ---- | ---------------------------------------------------- |
| `/v1/session/wallet/nonce`  | `GET`  | None | Issue a one-time nonce for an address (5-minute TTL) |
| `/v1/session/wallet/verify` | `POST` | None | Verify a signed SIWE message, issue a JWT            |

## Flow

```
agent                                       client-api
  │                                              │
  │  GET /v1/session/wallet/nonce?address=0x..   │
  ├─────────────────────────────────────────────▶│
  │                                              │   stores nonce in Redis
  │  { "nonce": "abc..." }                       │   under key siwe:nonce:0x...
  │◀─────────────────────────────────────────────┤
  │                                              │
  │  build SIWE message with the nonce           │
  │  personal_sign(message)                      │
  │                                              │
  │  POST /v1/session/wallet/verify              │
  │       { message, signature }                 │
  ├─────────────────────────────────────────────▶│
  │                                              │   parses message → recovers signer
  │                                              │   compares to nonce-bound address
  │                                              │   consumes nonce (one-shot)
  │                                              │   creates user if first-time
  │  { token, expiresAt, user }                  │   issues JWT (also as httpOnly cookie)
  │◀─────────────────────────────────────────────┤
```

## Required SIWE message format

The backend uses a **minimal parser**. It only requires:

* The signer's address (anywhere in the message, matched as the first `0x[0-9a-fA-F]{40}`)
* A line of the form `Nonce: <nonce>`

It accepts the standard EIP-4361 layout, e.g.:

```
kumbaya.xyz wants you to sign in with your Ethereum account:
0xYOUR_AGENT_WALLET

Sign in to Kumbaya as an agent.

URI: https://kumbaya.xyz
Version: 1
Chain ID: 4326
Nonce: <nonce-from-step-1>
Issued At: 2026-01-30T12:00:00Z
```

**Sign with `personal_sign`** (the EIP-191 prefixed-message variant). `viem`'s `signMessage`, `ethers`' `signer.signMessage`, and most wallet libraries default to this.

## End-to-end example (viem)

```ts
import { privateKeyToAccount } from 'viem/accounts'

const BASE = 'https://clients.kumbaya.xyz'

const account = privateKeyToAccount(process.env.AGENT_PRIVATE_KEY as `0x${string}`)

// 1. Get a nonce
const { nonce } = await fetch(
  `${BASE}/v1/session/wallet/nonce?address=${account.address}`,
).then(r => r.json())

// 2. Build SIWE message
const message = [
  `kumbaya.xyz wants you to sign in with your Ethereum account:`,
  account.address,
  ``,
  `Sign in to Kumbaya as an agent.`,
  ``,
  `URI: https://kumbaya.xyz`,
  `Version: 1`,
  `Chain ID: 4326`,
  `Nonce: ${nonce}`,
  `Issued At: ${new Date().toISOString()}`,
].join('\n')

// 3. Sign
const signature = await account.signMessage({ message })

// 4. Verify
const session = await fetch(`${BASE}/v1/session/wallet/verify`, {
  method:  'POST',
  headers: { 'content-type': 'application/json' },
  body:    JSON.stringify({ message, signature }),
}).then(r => {
  if (!r.ok) throw new Error(`SIWE verify failed: ${r.status}`)
  return r.json()
})

console.log('JWT:',         session.token)
console.log('Expires at:',  session.expiresAt)
console.log('Agent userId:', session.user.id)
```

## Using the JWT

Pass the token on subsequent requests via either:

* **Bearer header**: `Authorization: Bearer <jwt>`
* **httpOnly cookie**: the verify response also sets a session cookie automatically (use this if your agent shares a cookie jar)

```ts
const me = await fetch(`${BASE}/v1/session/current`, {
  headers: { Authorization: `Bearer ${session.token}` },
}).then(r => r.json())
```

## Refresh

JWTs are short-lived. Call `POST /v1/session/refresh` (with the existing JWT) to rotate, or simply re-run the SIWE flow when your token expires.

For long-running agents, the simplest pattern is: **request a fresh JWT on startup, refresh on a timer, re-do SIWE on any refresh failure.**

## Errors

| Status | Body                                           | Cause                                                   |
| ------ | ---------------------------------------------- | ------------------------------------------------------- |
| 400    | `{ "error": "Invalid SIWE message format" }`   | Couldn't parse address or nonce from the message        |
| 401    | `{ "error": "Invalid or expired nonce" }`      | Nonce doesn't match what was issued, or expired (5 min) |
| 401    | `{ "error": "Signature verification failed" }` | Recovered signer ≠ message address                      |
| 503    | `{ "error": "Service unavailable" }`           | Backend can't reach Redis (nonce store)                 |

## Notes for production

* **Nonce TTL is 5 minutes.** Don't pre-fetch nonces hours ahead.
* **Nonces are single-use.** Reusing a nonce after a successful verify will fail.
* **The address-in-message must match the recovered signer.** No "claim someone else's account" loophole.
* **Wallet-only users get a synthetic `privyDid`** of `wallet:<address>`. If a Privy user later imports the same wallet, the records merge implicitly - but for agent-only flows, this is usually moot.
* **You can sign in from any chain.** The `Chain ID` field in the SIWE message is informational; the parser doesn't enforce it. (That said, for clarity, set it to `4326` mainnet or `6343` testnet.)
