A web app for storing key-value pairs with Ethereum wallet authentication using Sign-In with Ethereum (SIWE). Each key can be marked as public or private, with private keys requiring OAuth scopes for access.
- Ethereum Authentication: Sign in with your Ethereum wallet using SIWE (EIP-4361)
- Key-Value Storage: Store arbitrary JSON data with user-defined keys
- Public/Private Visibility: Mark keys as public (accessible without auth) or private
- Per-Key Scopes: Private keys require
<key>:readOAuth scope for external access - Smart Contract Wallet Support: EIP-1271 signature verification for Safe, Argent, etc.
- Persistent Storage: SQLite database with Railway volume support
Frontend
- React + Vite
- wagmi/viem for Ethereum interactions
- TanStack Query for async state management
Backend
- Bun runtime
- Hono web framework
- SQLite (bun:sqlite)
- jose for JWT handling
┌─────────────────────────────────────────────────────────────┐
│ Frontend │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ wagmi │ │ React │ │ TanStack Query │ │
│ │ (wallet) │ │ (UI) │ │ (state) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Hono API Server │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Auth │ │ Key-Value │ │ Vite Dev Server │ │
│ │ /api/auth │ │ /api/keys │ │ (dev only) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SQLite │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ key_values │ │ nonces │ │
│ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
sequenceDiagram
participant User
participant App
participant Wallet
participant Server
User->>App: Connect wallet
App->>Wallet: Request connection
Wallet-->>App: Connected (address)
App->>Server: GET /api/auth/nonce
Server-->>App: { nonce }
App->>App: Build SIWE message
App->>Wallet: Sign message
Wallet->>User: Approve signature?
User->>Wallet: Approve
Wallet-->>App: signature
App->>Server: POST /api/auth/token { message, signature }
Server->>Server: Verify signature (EOA/EIP-1271)
Server-->>App: { access_token, expires_in }
App->>Server: GET /api/keys (Bearer token)
Server-->>App: keys[]
- User connects wallet (MetaMask, etc.)
- App switches to Ethereum mainnet
- App fetches a nonce from
/api/auth/nonce - App builds a SIWE message and requests wallet signature
- App sends message + signature to
/api/auth/token - Server verifies signature using
viem.verifyMessage()(supports EOA + EIP-1271) - Server issues a JWT access token (1 hour expiry)
- Frontend includes token in
Authorization: Bearer <token>header for API requests
For server-to-server API access, the /user/:address/:key endpoint implements discoverable OAuth for private keys:
sequenceDiagram
participant Client
participant Wallet
participant Server
Client->>Server: GET /user/:address/:key
alt Key is public
Server-->>Client: 200 { key, value }
else Key is private
Server-->>Client: 401 + WWW-Authenticate header
Note right of Client: Parse challenge:<br/>scope, token_uri,<br/>chain_id, signing_scheme
Client->>Server: GET /api/auth/nonce
Server-->>Client: { nonce }
Client->>Client: Build SIWE message with scopes
Client->>Wallet: Sign message
Wallet-->>Client: signature
Client->>Server: POST /api/auth/token<br/>{ grant_type: "eth_signature",<br/>message, signature, scope }
Server->>Server: Verify signature & scopes
Server-->>Client: { access_token, scope }
Client->>Server: GET /user/:address/:key<br/>(Bearer token)
Server-->>Client: { key, value }
end
- Client requests the endpoint without authentication
- If the key is public, the value is returned immediately
- If the key is private, server responds with
401 UnauthorizedandWWW-Authenticateheader:WWW-Authenticate: Bearer, realm="kv-settings", scope="settings:read", token_uri="https://api.example.com/api/auth/token", chain_id="1", signing_scheme="eip4361" - Client constructs a SIWE message with scopes in
resourcesfield:Resources: - urn:oauth:scope:settings:read - Client signs the message and exchanges for a token:
POST /api/auth/token { "grant_type": "eth_signature", "message": "<SIWE message>", "signature": "0x...", "scope": "settings:read" }
- Server returns an OAuth-compatible token response:
{ "access_token": "<jwt>", "token_type": "Bearer", "expires_in": 3600, "scope": "settings:read" } - Client retries with
Authorization: Bearer <token>
GET /api/auth/nonce- Get a fresh nonce for SIWEPOST /api/auth/token- Exchange signed SIWE message for JWT (supportseth_signaturegrant type)
GET /user/:address/:key- Get a value by owner address and key- Public keys: Returns value without authentication
- Private keys: Returns
401withWWW-Authenticatechallenge requiring<key>:readscope
GET /api/keys- List all keys for authenticated userPUT /api/keys/:key- Create or update a key-value pairDELETE /api/keys/:key- Delete a key
PUT /api/keys/profile
Authorization: Bearer <token>
Content-Type: application/json
{
"value": {"name": "Alice", "bio": "Developer"},
"isPublic": true
}Response:
{
"key": "profile",
"value": {"name": "Alice", "bio": "Developer"},
"isPublic": true,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}GET /user/0xabc.../profileResponse:
{
"key": "profile",
"value": {"name": "Alice", "bio": "Developer"},
"owner": "0xabc...",
"isPublic": true
}GET /user/0xabc.../settings
Authorization: Bearer <token with settings:read scope>Response:
{
"key": "settings",
"value": {"theme": "dark", "notifications": true},
"owner": "0xabc...",
"isPublic": false
}<key>:read- Read a specific private key (e.g.,profile:read,settings:read)kv:write- Create/update/delete keys (owner operations)
# Install dependencies
bun install
# Create .env file
echo "JWT_SECRET=$(openssl rand -base64 32)" > .env
# Start dev server
bun run dev# Build frontend
bun run build
# Start production server
NODE_ENV=production JWT_SECRET=your-secret DATA_DIR=/app/data bun server/index.ts- Connect your GitHub repo to Railway
- Add environment variables:
JWT_SECRET- Generate withopenssl rand -base64 32
- Add a persistent volume mounted at
/app/data - Deploy
The app uses nixpacks.toml for build configuration and railway.json for deployment settings.
- Private Data Client - Sample client demonstrating the discoverable OAuth flow
- EIP-4361: Sign-In with Ethereum