How Buzz Implements NIP-98 Authentication for HTTP Endpoints
Buzz implements NIP-98 authentication by embedding a signed, time-bound Nostr event in the Authorization header, which the server verifies against expected URLs, request payloads, and replay guards before processing.
Block's Buzz relay secures its HTTP API using the NIP-98 standard, extending Nostr's cryptographic identity model to REST endpoints. This implementation embeds ephemeral authentication events directly into HTTP headers, providing the same security guarantees as NIP-42 WebSocket authentication while maintaining stateless request semantics. Every authenticated request flows through four tightly-coupled verification layers implemented across the buzz-relay and buzz-auth crates.
Authorization Header Construction
The client-side implementation centers on build_nip98_auth_header in buzz-relay/src/relay.rs (lines 118-130). This function accepts the HTTP method, full request URL, optional request body bytes, and the tenant's signing keys to generate a fresh NIP-98 event.
The resulting event uses the current Unix timestamp for created_at and includes tags binding it to the specific HTTP verb and payload. The helper serializes this event into the standard Authorization: Nostr <event-json> header format. For advanced use cases, build_nip98_auth_header_for_keys provides a lower-level interface that accepts raw Keys instead of the full relay state.
use buzz_relay::relay::{build_nip98_auth_header_for_keys, Method};
use nostr::Keys;
let keys = Keys::new(...);
let auth_header = build_nip98_auth_header_for_keys(
&keys,
&Method::POST,
"https://relay.example.com/events",
b"{\"kind\":1}"
).expect("failed to sign NIP-98");
URL Binding and Request Context
To prevent credential theft across communities, Buzz derives the expected verification URL using nip98_expected_url in buzz-relay/src/api/bridge.rs (lines 191-210). This function constructs the canonical URL by combining the relay's public base URL, the community (tenant) identifier, and the specific request path.
This binding ensures that a NIP-98 event signed for one community cannot be replayed against another, even if an attacker obtains the raw authorization header. The expected URL becomes part of the signed event's tag structure, cryptographically tying the authentication to the exact host and path combination.
Replay Protection Mechanism
Before accepting any request, Buzz checks the event ID against the Nip98ReplayGuard implemented in buzz-auth/src/nip98_replay.rs. The guard stores a deterministic replay key using the format buzz:{community}:nip98:{event_id_hex} in a per-community cache.
The check_nip98_replay function rejects any event ID that has been seen within the retention window, effectively mitigating replay attacks. The guard is injected into the relay state via state.nip98_replay, making it available to all HTTP handlers without additional configuration.
Server-Side Event Verification
The final validation happens in buzz-auth/src/nip98.rs through verify_nip98_event. This function performs three critical checks:
- Cryptographic integrity: Verifies the event signature against the public key embedded in the event.
- Context binding: Confirms the event's URL tag matches the
nip98_expected_urlderivation and that the HTTP method tag matches the incoming request. - Temporal freshness: Validates that the event's
created_attimestamp falls within a configurable freshness window to prevent stale authentication.
If the request includes a body, the verification also checks the SHA-256 hash of the payload against the event's payload tag.
Implementation Examples
Manual POST Request with Body
When you need full control over the signing process, use the low-level helper to authenticate POST requests with JSON payloads:
use buzz_auth::nip98::{HttpMethod, HttpData};
use buzz_relay::relay::build_nip98_auth_header_for_keys;
use nostr::Keys;
let keys = Keys::from_env("BUZZ_PRIVATE_KEY")?;
let body = b"{\"kind\":1,\"content\":\"Hello\"}";
let auth = build_nip98_auth_header_for_keys(
&keys,
&Method::POST,
"https://relay.example.com/events",
body
)?;
let resp = reqwest::Client::new()
.post("https://relay.example.com/events")
.header("Authorization", auth)
.body(body)
.send()
.await?;
High-Level GET Requests
For read operations, the high-level helper in the Tauri bridge handles key management automatically:
use buzz_relay::relay::build_nip98_auth_header;
let url = "https://relay.example.com/query";
let auth = build_nip98_auth_header(&Method::GET, url, &[], &state)?;
let resp = reqwest::Client::new()
.get(url)
.header("Authorization", auth)
.send()
.await?;
CLI Automation
The Buzz CLI automatically injects NIP-98 headers when contacting relays, loading keys from the BUZZ_PRIVATE_KEY environment variable:
buzz events create --kind 1 --content "Authenticated via NIP-98"
Summary
- Header Generation:
build_nip98_auth_headerinbuzz-relay/src/relay.rscreatesAuthorization: Nostr <event>headers with fresh timestamps. - URL Isolation:
nip98_expected_urlbinds events to specific community URLs, preventing cross-tenant credential replay. - Replay Defense: The
Nip98ReplayGuardinbuzz-auth/src/nip98_replay.rsstores event IDs underbuzz:{community}:nip98:{event_id_hex}keys to block duplicate submissions. - Verification:
verify_nip98_eventinbuzz-auth/src/nip98.rsvalidates signatures, URL tags, HTTP methods, and timestamp freshness.
Frequently Asked Questions
How does NIP-98 authentication differ from NIP-42 in Buzz?
While NIP-42 handles WebSocket AUTH messages for persistent connections, Buzz uses NIP-98 for stateless HTTP requests. NIP-98 embeds the authentication event directly in the Authorization header of each request, whereas NIP-42 establishes a session through a WebSocket handshake. Both use the same cryptographic primitives, but NIP-98 includes the HTTP method and request body hash in the signed event.
What prevents an attacker from replaying a captured NIP-98 header?
Buzz implements the Nip98ReplayGuard which maintains a cache of recently seen event IDs per community. When check_nip98_replay processes a request, it checks for the existence of buzz:{community}:nip98:{event_id_hex} in the cache and rejects duplicates. Combined with the strict created_at freshness window enforced by verify_nip98_event, this renders captured headers useless after their first use or after expiration.
Why does Buzz include the community identifier in the expected URL?
The nip98_expected_url function includes the community ID to scope authentication events to specific tenants. This prevents a user from extracting a valid Authorization header sent to Community A and using it to access resources in Community B, even if both communities reside on the same Buzz relay instance. The community identifier becomes part of the signed URL tag, creating a cryptographic boundary between tenants.
What happens if the created_at timestamp is slightly off?
verify_nip98_event rejects events where the created_at timestamp falls outside a configurable freshness window (typically a few minutes). This prevents attackers from pre-signing events for later use. Clients must generate a fresh NIP-98 event for each request with a current timestamp, which the build_nip98_auth_header helper handles automatically.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →