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:

  1. Register the route in crates/buzz-relay/src/router.rs (or the appropriate sub-router).

  2. Extract and decode the Authorization header using decode_auth_header to obtain the raw event JSON.

  3. Verify the event by calling verify_nip98_event with:

    • 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
  4. Extract the pubkey from the verification result to identify the caller.

  5. Enforce access control by checking if the pubkey belongs to an admin or authorized user, returning 403 Forbidden for unauthorized roles.

  6. Return appropriate status codes: 401 Unauthorized for verification failures, 403 Forbidden for role mismatches, and 200 OK for 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_keys in Rust or createNip98Header in TypeScript, which produce an Authorization: Nostr <base64-event> string containing the signed event.
  • Verify requests by calling verify_nip98_event from crates/buzz-auth/src/nip98.rs, which validates the signature, checks that the u tag matches the request URL, confirms the method tag matches the HTTP verb, and verifies the b tag (SHA-256 body hash) if a body is present.
  • Prevent replays through the nip98_replay guard in crates/buzz-auth/src/nip98_replay.rs, which enforces the BUZZ_NIP98_MAX_AGE freshness 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →