How to Implement NIP-98 HTTP Authentication for REST Endpoints in Buzz
Buzz authenticates HTTP API requests using NIP-98, where clients generate a base64-encoded signed Nostr event for the Authorization header, and servers verify it using the verify_nip98_event function in crates/buzz-auth/src/nip98.rs to extract the caller's public key and validate request integrity.
The block/buzz relay secures its REST endpoints—such as POST /events, POST /query, and webhook handlers—using NIP-98 HTTP authentication rather than traditional API keys. This protocol cryptographically binds the caller's identity to the specific HTTP request, ensuring that only holders of valid Nostr private keys can access protected resources while preventing replay attacks and request tampering.
How NIP-98 Authentication Works in Buzz
The NIP-98 implementation in Buzz follows a strict request-signing and verification pipeline. When a client initiates a request, it creates a Nostr event containing the HTTP method, the full request URL (including scheme, host, and path), an optional SHA-256 hash of the request body, and a timestamp. This event is signed with the caller's private key, base64-encoded, and sent as an Authorization: Nostr <event> header.
On the server side, the verify_nip98_event function validates the cryptographic signature, ensures the u tag matches the exact URL being processed, confirms the method tag matches the HTTP verb, verifies the b tag (body hash) if present, and checks that the created_at timestamp falls within the configured BUZZ_NIP98_MAX_AGE window. The nip98_replay guard in crates/buzz-auth/src/nip98_replay.rs provides additional replay protection by tracking consumed event IDs.
Server-Side Implementation
Validating the Authorization Header
To protect a new endpoint, extract the Authorization header at the handler entry point and call verify_nip98_event. This function is defined in crates/buzz-auth/src/nip98.rs and returns the signer's public key upon successful validation.
use axum::{
extract::{State, Json},
http::{HeaderMap, StatusCode},
};
use buzz_auth::{verify_nip98_event, nip98::decode_auth_header};
use serde::Deserialize;
#[derive(Deserialize)]
struct MyPayload {
message: String,
}
pub async fn my_endpoint(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<MyPayload>,
) -> Result<Json<Success>, StatusCode> {
// Extract and decode the NIP-98 header
let auth_header = headers
.get(axum::http::header::AUTHORIZATION)
.ok_or(StatusCode::UNAUTHORIZED)?;
let event_json = decode_auth_header(auth_header).map_err(|_| StatusCode::UNAUTHORIZED)?;
// Verify against the concrete URL and HTTP method
let expected_url = format!("{}/my/custom/endpoint", state.config.relay_url);
let signer = verify_nip98_event(
&event_json,
&expected_url,
"POST",
Some(&serde_json::to_vec(&payload).unwrap()),
)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
// Optional: Role-based access control
if !state.is_admin(&signer) {
return Err(StatusCode::FORBIDDEN);
}
// Proceed with endpoint logic
Ok(Json(Success { ok: true }))
}
Replay Protection
The nip98_replay module in crates/buzz-auth/src/nip98_replay.rs maintains a record of previously used event IDs to prevent replay attacks. When verify_nip98_event is called within the HTTP bridge flow (as seen in crates/buzz-relay/src/api/bridge.rs), it automatically checks against this guard to ensure the event has not been consumed previously.
Client-Side Implementation
Rust Client
Use the build_nip98_auth_header_for_keys helper from crates/buzz-relay/src/relay.rs to generate the authorization header. This function handles event construction, signing, and base64 encoding.
use buzz_auth::nip98::build_nip98_auth_header_for_keys;
use reqwest::Method;
use nostr::Keys;
fn post_with_nip98(state: &AppState, payload: &MyPayload) -> anyhow::Result<()> {
let keys = Keys::from_sk_str(&std::env::var("BUZZ_PRIVATE_KEY")?)?;
let url = format!("{}/my/custom/endpoint", state.config.relay_url);
let body = serde_json::to_vec(payload)?;
// Generate the Authorization: Nostr <event> header
let auth = build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body)?;
let client = reqwest::blocking::Client::new();
let resp = client
.post(&url)
.header("Authorization", auth)
.body(body)
.send()?;
println!("Status: {}", resp.status());
Ok(())
}
TypeScript Client
For browser-based or Node.js clients, use the createNip98Header function from web/src/shared/lib/nip98.ts, which mirrors the Rust implementation.
import { createNip98Header } from '@/shared/lib/nip98';
import { Keys } from '@nostr-dev-kit/nostr-tools';
async function callMyEndpoint(payload: object) {
const keys = Keys.fromPrivateKey(process.env.BUZZ_PRIVATE_KEY!);
const url = `${process.env.BUZZ_RELAY_URL}/my/custom/endpoint`;
const body = JSON.stringify(payload);
const auth = await createNip98Header(keys, 'POST', url, body);
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: auth,
},
body: body,
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return await resp.json();
}
Adding NIP-98 to a New Endpoint
Follow this pattern to secure any new REST endpoint in Buzz:
-
Register the route in
crates/buzz-relay/src/router.rs(or the appropriate sub-router). -
Extract and decode the
Authorizationheader usingdecode_auth_headerto obtain the raw event JSON. -
Verify the event by calling
verify_nip98_eventwith:- The decoded event JSON
- The exact expected URL (including scheme and host)
- The HTTP method string (e.g.,
"POST","GET") - An optional reference to the request body bytes
-
Extract the pubkey from the verification result to identify the caller.
-
Enforce access control by checking if the pubkey belongs to an admin or authorized user, returning
403 Forbiddenfor unauthorized roles. -
Return appropriate status codes:
401 Unauthorizedfor verification failures,403 Forbiddenfor role mismatches, and200 OKfor successful authentication.
Key Source Files
| Responsibility | File Path | Key Components |
|---|---|---|
| Server-side verification | crates/buzz-auth/src/nip98.rs |
verify_nip98_event, decode_auth_header |
| Replay protection | crates/buzz-auth/src/nip98_replay.rs |
nip98_replay guard, event ID tracking |
| HTTP bridge handlers | crates/buzz-relay/src/api/bridge.rs |
Entry points for /events, /query, /count |
| Client header generation (Rust) | crates/buzz-relay/src/relay.rs |
build_nip98_auth_header_for_keys, build_nip98_auth_header |
| Route registration | crates/buzz-relay/src/router.rs |
Route definitions requiring NIP-98 |
| Client header generation (TypeScript) | web/src/shared/lib/nip98.ts |
createNip98Header |
| Integration tests | crates/buzz-test-client/tests/conformance_multitenant.rs |
End-to-end NIP-98 usage examples |
Summary
- Generate headers using
build_nip98_auth_header_for_keysin Rust orcreateNip98Headerin TypeScript, which produce anAuthorization: Nostr <base64-event>string containing the signed event. - Verify requests by calling
verify_nip98_eventfromcrates/buzz-auth/src/nip98.rs, which validates the signature, checks that theutag matches the request URL, confirms themethodtag matches the HTTP verb, and verifies thebtag (SHA-256 body hash) if a body is present. - Prevent replays through the
nip98_replayguard incrates/buzz-auth/src/nip98_replay.rs, which enforces theBUZZ_NIP98_MAX_AGEfreshness window and tracks consumed event IDs. - Extract identity from the returned public key to implement role-based access control, checking against admin lists or user permissions before proceeding with business logic.
Frequently Asked Questions
What fields are required in a NIP-98 event for Buzz authentication?
A valid NIP-98 event must include a u tag containing the full request URL, a method tag with the HTTP verb (e.g., POST), an optional b tag with the SHA-256 hash of the request body, and a created_at timestamp within the allowed freshness window. The event must be signed with a valid Nostr private key and encoded in base64 for the Authorization header.
How does Buzz prevent replay attacks with NIP-98?
The nip98_replay module in crates/buzz-auth/src/nip98_replay.rs stores the IDs of previously used NIP-98 events and rejects any attempt to reuse them. Additionally, the verify_nip98_event function enforces the BUZZ_NIP98_MAX_AGE configuration to ensure events are recent, preventing the reuse of old signatures.
Can NIP-98 be used for GET requests without a body?
Yes. For GET requests, omit the body when calling build_nip98_auth_header_for_keys or pass an empty buffer. The b tag will be excluded from the signed event, and verify_nip98_event will skip the body hash verification when the body parameter is None or empty.
How do I restrict an endpoint to specific users after verifying NIP-98?
After calling verify_nip98_event, use the returned public key (pubkey) to check against your authorization layer. For example, verify the pubkey exists in an admin list configured in crates/buzz-relay/src/config.rs (via the AdminAuth enum) or query a database for user roles. Return 403 Forbidden if the user lacks required permissions, or 401 Unauthorized if the NIP-98 verification itself fails.
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 →