# How to Implement NIP-98 HTTP Authentication for REST Endpoints in Buzz

> Learn to implement NIP-98 HTTP authentication for REST endpoints in Buzz. Secure your API with Nostr event verification and extract caller public keys efficiently.

- Repository: [Block Open Source/buzz](https://github.com/block/buzz)
- Tags: how-to-guide
- Published: 2026-08-29

---

**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`](https://github.com/block/buzz/blob/main/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`](https://github.com/block/buzz/blob/main/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`](https://github.com/block/buzz/blob/main/crates/buzz-auth/src/nip98.rs) and returns the signer's public key upon successful validation.

```rust
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`](https://github.com/block/buzz/blob/main/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`](https://github.com/block/buzz/blob/main/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`](https://github.com/block/buzz/blob/main/crates/buzz-relay/src/relay.rs) to generate the authorization header. This function handles event construction, signing, and base64 encoding.

```rust
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`](https://github.com/block/buzz/blob/main/web/src/shared/lib/nip98.ts), which mirrors the Rust implementation.

```typescript
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`](https://github.com/block/buzz/blob/main/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`](https://github.com/block/buzz/blob/main/crates/buzz-auth/src/nip98.rs) | `verify_nip98_event`, `decode_auth_header` |
| **Replay protection** | [`crates/buzz-auth/src/nip98_replay.rs`](https://github.com/block/buzz/blob/main/crates/buzz-auth/src/nip98_replay.rs) | `nip98_replay` guard, event ID tracking |
| **HTTP bridge handlers** | [`crates/buzz-relay/src/api/bridge.rs`](https://github.com/block/buzz/blob/main/crates/buzz-relay/src/api/bridge.rs) | Entry points for `/events`, `/query`, `/count` |
| **Client header generation (Rust)** | [`crates/buzz-relay/src/relay.rs`](https://github.com/block/buzz/blob/main/crates/buzz-relay/src/relay.rs) | `build_nip98_auth_header_for_keys`, `build_nip98_auth_header` |
| **Route registration** | [`crates/buzz-relay/src/router.rs`](https://github.com/block/buzz/blob/main/crates/buzz-relay/src/router.rs) | Route definitions requiring NIP-98 |
| **Client header generation (TypeScript)** | [`web/src/shared/lib/nip98.ts`](https://github.com/block/buzz/blob/main/web/src/shared/lib/nip98.ts) | `createNip98Header` |
| **Integration tests** | [`crates/buzz-test-client/tests/conformance_multitenant.rs`](https://github.com/block/buzz/blob/main/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`](https://github.com/block/buzz/blob/main/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`](https://github.com/block/buzz/blob/main/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`](https://github.com/block/buzz/blob/main/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`](https://github.com/block/buzz/blob/main/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.