# Nostr HTTP Auth (NIP-98) IndeeHub exposes an HTTP authentication path that accepts requests signed by Nostr keys (NIP-98). Clients sign each request body + method + URL and send it via the `Authorization: Nostr ` header. The server verifies the event, enforces a tight replay window, and makes the caller’s pubkey available on the Express request. ## Request format - Header: `Authorization: Nostr `. - Event requirements: `kind` 27235, `created_at` within ±120s of server time, and the following tags: - `["method", ""]` - `["u", ""]` (include scheme, host, path, and query; exclude fragment) - `["payload", ""]` (omit or leave empty only when the request has no body) ## Client examples ### Browser (NIP-07) ```ts async function fetchWithNostr(url: string, method: string, body?: unknown) { const payload = body ? JSON.stringify(body) : ''; const tags = [ ['method', method], ['u', url], ['payload', payload ? sha256(payload) : ''], ]; const unsigned = { kind: 27235, created_at: Math.floor(Date.now() / 1000), tags, content: '', }; const event = await window.nostr.signEvent(unsigned); const authorization = `Nostr ${btoa(JSON.stringify(event))}`; return fetch(url, { method, headers: { 'content-type': 'application/json', authorization, }, body: payload || undefined, }); } ``` ### Node (nostr-tools) ```ts import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools'; import crypto from 'node:crypto'; const secret = generateSecretKey(); const pubkey = getPublicKey(secret); const payload = JSON.stringify({ ping: 'pong' }); const unsigned = { kind: 27235, created_at: Math.floor(Date.now() / 1000), tags: [ ['u', 'https://api.indeehub.studio/nostr-auth/echo'], ['method', 'POST'], ['payload', crypto.createHash('sha256').update(payload).digest('hex')], ], content: '', }; const event = finalizeEvent(unsigned, secret); const authorization = `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`; ``` ## Server usage - Apply the guard: `@UseGuards(NostrAuthGuard)` to require Nostr signatures. - Hybrid mode: `@UseGuards(HybridAuthGuard)` accepts either Nostr (NIP-98) or the existing JWT guard. - Access the caller: `req.nostrPubkey` and `req.nostrEvent` are populated on successful verification. - Session bridge: `POST /auth/nostr/session` (guarded by `NostrAuthGuard`) exchanges a signed HTTP request for a 15-minute JWT (`sub = pubkey`) plus refresh token. `POST /auth/nostr/refresh` exchanges a refresh token for a new pair. Use `@UseGuards(NostrSessionJwtGuard)` or `HybridAuthGuard` to accept these JWTs. - Link an existing user: `POST /auth/nostr/link` with both `JwtAuthGuard` (current user) and `NostrAuthGuard` (NIP-98 header) to attach a pubkey to the user record. `POST /auth/nostr/unlink` removes it. When a pubkey is linked, Nostr session JWTs also include `uid` so downstream code can attach `req.user`. If you need both JWT + Nostr on the same call, send the Nostr signature in `nostr-authorization` (or `x-nostr-authorization`) header so it doesn’t conflict with `Authorization: Bearer ...`. Example: ```ts @Post('protected') @UseGuards(NostrAuthGuard) handle(@Req() req: Request) { return { pubkey: req.nostrPubkey }; } ``` ## Troubleshooting - Clock skew: ensure client and server clocks are within ±2 minutes. - URL canonicalization: sign the exact scheme/host/path/query received by the server (no fragments). - Payload hash: hash the raw request body bytes; any whitespace or re-serialization changes will fail verification. - Headers: set the `Host` and (if behind proxies) `x-forwarded-proto` to the values used when signing the URL. - Stale signatures: rotate signatures per request; reuse will fail once outside the replay window.