NIP-42

Authentication of clients to relays

draft relay features

NIP-42 enables relays to authenticate clients using signed AUTH events (kind 22242), supporting paid relays, private access, and identity verification.

Author
fiatjaf, Kukks, arthurfranca
Last Updated
25 January 2024
Official Spec
View on GitHub →

NIP-42: Authentication of clients to relays

Status: Draft Authors: fiatjaf, Kukks, arthurfranca Category: Relay Features


Overview

NIP-42 defines how relays can authenticate clients using a challenge-response mechanism, verifying user identity before granting access.

Core Concept:

  • Relay sends AUTH challenge to client
  • Client signs challenge with private key
  • Relay verifies signature and grants access
  • Enables paid relays, private access, rate limiting

Authentication Flow:

  1. Client connects to relay
  2. Relay sends AUTH challenge
  3. Client signs and returns AUTH event (kind 22242)
  4. Relay verifies and authorizes client

Use Cases:

  • Paid relays: Verify subscription status
  • Private relays: Restrict access to members
  • Rate limiting: Different limits per user
  • Access control: Whitelist/blacklist management
  • Analytics: Track authenticated usage
  • Compliance: Identity verification when required

Status: Draft (implemented by major relay software)


Why Authentication Matters

Without NIP-42

Problems before authentication:

  • No way to verify client identity
  • Can’t implement paid relay subscriptions
  • Difficult to enforce access controls
  • Rate limiting applies to all clients equally
  • No protection against anonymous abuse

With NIP-42

Benefits:

  • Identity verification: Know who’s connecting
  • Paid relays: Verify subscriptions before access
  • Access control: Private/member-only relays
  • Fair rate limiting: Per-user quotas
  • Abuse prevention: Ban specific users, not IPs
  • Analytics: Understand authenticated usage

Use Cases

  1. Paid Relay Services: Verify payment before granting access
  2. Private Communities: Members-only relay access
  3. Rate Limiting: Higher limits for verified users
  4. Spam Prevention: Authenticate before posting
  5. Compliance: Verify identity for regulated content
  6. Premium Features: Extra capabilities for authenticated users

How It Works

Challenge-Response Flow

Client                          Relay
  |                               |
  |--- WebSocket Connect -------->|
  |                               |
  |<--- AUTH Challenge (JSON) ----|
  |     ["AUTH", "<challenge>"]   |
  |                               |
  |--- AUTH Event (signed) ------>|
  |     ["AUTH", {...event}]      |
  |                               |
  |<--- OK (acceptance) -----------|
  |     ["OK", "...", true, ""]   |
  |                               |
  |<--- Relay now accepts reqs -->|

AUTH Challenge Format

Relay sends challenge to client:

["AUTH", "<challenge-string>"]

Challenge string:

  • Random, unique per connection
  • Used once (prevents replay attacks)
  • No specific format required (relay decides)

Example:

["AUTH", "d5f8a9c2-3b1e-4f2d-9a7c-8e3f1d4b2a6c"]

AUTH Event Structure

Client responds with kind 22242 event:

{
  "id": "event-id",
  "pubkey": "client-pubkey",
  "created_at": 1673347337,
  "kind": 22242,
  "tags": [
    ["relay", "wss://relay.example.com"],
    ["challenge", "d5f8a9c2-3b1e-4f2d-9a7c-8e3f1d4b2a6c"]
  ],
  "content": "",
  "sig": "signature..."
}

Required Tags:

  • relay - URL of the relay being authenticated to
  • challenge - Challenge string sent by relay

Client Implementation

Basic Authentication

import { getPublicKey, signEvent } from 'nostr-tools';

class AuthenticatedClient {
  constructor(relayUrl, privateKey) {
    this.relayUrl = relayUrl;
    this.privateKey = privateKey;
    this.pubkey = getPublicKey(privateKey);
    this.ws = null;
    this.authenticated = false;
  }

  async connect() {
    return new Promise((resolve, reject) => {
      this.ws = new WebSocket(this.relayUrl);

      this.ws.onopen = () => {
        console.log("Connected to relay, waiting for AUTH challenge...");
      };

      this.ws.onmessage = async (event) => {
        const message = JSON.parse(event.data);

        if (message[0] === "AUTH") {
          await this.handleAuthChallenge(message[1]);
        } else if (message[0] === "OK" && !this.authenticated) {
          this.authenticated = true;
          console.log("Authentication successful!");
          resolve();
        }
      };

      this.ws.onerror = (error) => {
        reject(error);
      };
    });
  }

  async handleAuthChallenge(challenge) {
    console.log("Received AUTH challenge:", challenge);

    // Create AUTH event
    const authEvent = {
      kind: 22242,
      created_at: Math.floor(Date.now() / 1000),
      tags: [
        ["relay", this.relayUrl],
        ["challenge", challenge]
      ],
      content: "",
      pubkey: this.pubkey
    };

    // Sign event
    const signedAuth = signEvent(authEvent, this.privateKey);

    // Send AUTH response
    this.ws.send(JSON.stringify(["AUTH", signedAuth]));

    console.log("Sent AUTH response");
  }

  async publish(event) {
    if (!this.authenticated) {
      throw new Error("Not authenticated");
    }

    this.ws.send(JSON.stringify(["EVENT", event]));
  }

  async subscribe(filters) {
    if (!this.authenticated) {
      throw new Error("Not authenticated");
    }

    const subId = Math.random().toString(36).substring(7);
    this.ws.send(JSON.stringify(["REQ", subId, ...filters]));
    return subId;
  }
}

// Usage
const client = new AuthenticatedClient(
  "wss://paid-relay.example.com",
  myPrivateKey
);

await client.connect();
console.log("Ready to publish and subscribe!");

await client.publish(myEvent);

Automatic Retry on Auth Failure

class RobustAuthClient extends AuthenticatedClient {
  constructor(relayUrl, privateKey, maxRetries = 3) {
    super(relayUrl, privateKey);
    this.maxRetries = maxRetries;
    this.retryCount = 0;
  }

  async connect() {
    try {
      await super.connect();
      this.retryCount = 0;  // Reset on success
    } catch (error) {
      if (this.retryCount < this.maxRetries) {
        this.retryCount++;
        console.log(`Auth failed, retrying (${this.retryCount}/${this.maxRetries})...`);
        await new Promise(resolve => setTimeout(resolve, 1000 * this.retryCount));
        return this.connect();
      }
      throw new Error(`Authentication failed after ${this.maxRetries} attempts`);
    }
  }
}

Relay Implementation

Challenge Generation

import { randomBytes } from 'crypto';

function generateChallenge() {
  return randomBytes(32).toString('hex');
}

// Store active challenges (with expiration)
const activeChallenge = new Map();

function createChallenge(wsConnectionId) {
  const challenge = generateChallenge();

  activeChallenges.set(wsConnectionId, {
    challenge,
    createdAt: Date.now(),
    expiresAt: Date.now() + (5 * 60 * 1000)  // 5 minutes
  });

  return challenge;
}

Challenge Verification

import { verifySignature } from 'nostr-tools';

async function verifyAuthEvent(authEvent, wsConnectionId, relayUrl) {
  // 1. Verify event structure
  if (authEvent.kind !== 22242) {
    return { valid: false, error: "Invalid event kind" };
  }

  // 2. Extract tags
  const relayTag = authEvent.tags.find(t => t[0] === "relay");
  const challengeTag = authEvent.tags.find(t => t[0] === "challenge");

  if (!relayTag || !challengeTag) {
    return { valid: false, error: "Missing required tags" };
  }

  // 3. Verify relay URL matches
  if (relayTag[1] !== relayUrl) {
    return { valid: false, error: "Relay URL mismatch" };
  }

  // 4. Verify challenge
  const challengeData = activeChallenges.get(wsConnectionId);

  if (!challengeData) {
    return { valid: false, error: "No challenge found for connection" };
  }

  if (challengeData.challenge !== challengeTag[1]) {
    return { valid: false, error: "Challenge mismatch" };
  }

  // 5. Check expiration
  if (Date.now() > challengeData.expiresAt) {
    activeChallenges.delete(wsConnectionId);
    return { valid: false, error: "Challenge expired" };
  }

  // 6. Verify timestamp freshness
  const eventAge = Date.now() / 1000 - authEvent.created_at;
  if (eventAge > 300) {  // 5 minutes
    return { valid: false, error: "Event too old" };
  }

  // 7. Verify signature
  if (!verifySignature(authEvent)) {
    return { valid: false, error: "Invalid signature" };
  }

  // 8. Cleanup used challenge
  activeChallenges.delete(wsConnectionId);

  return { valid: true, pubkey: authEvent.pubkey };
}

Access Control

class RelayWithAuth {
  constructor() {
    this.authenticatedClients = new Map();
    this.allowedPubkeys = new Set();  // Whitelist
  }

  async handleConnection(ws, connectionId) {
    // Send AUTH challenge
    const challenge = createChallenge(connectionId);
    ws.send(JSON.stringify(["AUTH", challenge]));

    // Wait for AUTH response
    ws.on('message', async (data) => {
      const message = JSON.parse(data);

      if (message[0] === "AUTH") {
        await this.handleAuthResponse(ws, message[1], connectionId);
      } else {
        // Check if authenticated before processing other messages
        if (!this.isAuthenticated(connectionId)) {
          ws.send(JSON.stringify([
            "NOTICE",
            "Authentication required. Please send AUTH event."
          ]));
          return;
        }

        // Process authenticated request
        this.handleAuthenticatedMessage(ws, message, connectionId);
      }
    });
  }

  async handleAuthResponse(ws, authEvent, connectionId) {
    const result = await verifyAuthEvent(authEvent, connectionId, this.relayUrl);

    if (result.valid) {
      // Check if user is allowed
      if (this.allowedPubkeys.size > 0 && !this.allowedPubkeys.has(result.pubkey)) {
        ws.send(JSON.stringify([
          "OK",
          authEvent.id,
          false,
          "restricted: pubkey not authorized"
        ]));
        ws.close();
        return;
      }

      // Mark as authenticated
      this.authenticatedClients.set(connectionId, {
        pubkey: result.pubkey,
        authenticatedAt: Date.now()
      });

      ws.send(JSON.stringify([
        "OK",
        authEvent.id,
        true,
        ""
      ]));

      console.log(`Client authenticated: ${result.pubkey}`);
    } else {
      ws.send(JSON.stringify([
        "OK",
        authEvent.id,
        false,
        `auth-required: ${result.error}`
      ]));
    }
  }

  isAuthenticated(connectionId) {
    return this.authenticatedClients.has(connectionId);
  }

  getAuthenticatedPubkey(connectionId) {
    return this.authenticatedClients.get(connectionId)?.pubkey;
  }
}

Advanced Use Cases

class PaidRelay extends RelayWithAuth {
  constructor(subscriptionChecker) {
    super();
    this.subscriptionChecker = subscriptionChecker;
  }

  async handleAuthResponse(ws, authEvent, connectionId) {
    const result = await verifyAuthEvent(authEvent, connectionId, this.relayUrl);

    if (!result.valid) {
      ws.send(JSON.stringify([
        "OK",
        authEvent.id,
        false,
        `auth-required: ${result.error}`
      ]));
      return;
    }

    // Check subscription status
    const hasSubscription = await this.subscriptionChecker.verify(result.pubkey);

    if (!hasSubscription) {
      ws.send(JSON.stringify([
        "OK",
        authEvent.id,
        false,
        "payment-required: Valid subscription required. Visit https://relay.example.com/subscribe"
      ]));
      ws.close();
      return;
    }

    // Grant access
    this.authenticatedClients.set(connectionId, {
      pubkey: result.pubkey,
      authenticatedAt: Date.now(),
      subscriptionTier: await this.subscriptionChecker.getTier(result.pubkey)
    });

    ws.send(JSON.stringify([
      "OK",
      authEvent.id,
      true,
      ""
    ]));
  }
}

Per-User Rate Limiting

class RateLimitedRelay extends RelayWithAuth {
  constructor() {
    super();
    this.userLimits = new Map();
  }

  checkRateLimit(pubkey) {
    const now = Date.now();
    const userLimit = this.userLimits.get(pubkey) || {
      requests: 0,
      windowStart: now
    };

    // Reset window if 1 minute has passed
    if (now - userLimit.windowStart > 60000) {
      userLimit.requests = 0;
      userLimit.windowStart = now;
    }

    // Check limit (e.g., 100 requests per minute)
    if (userLimit.requests >= 100) {
      return false;
    }

    userLimit.requests++;
    this.userLimits.set(pubkey, userLimit);
    return true;
  }

  handleAuthenticatedMessage(ws, message, connectionId) {
    const pubkey = this.getAuthenticatedPubkey(connectionId);

    if (!this.checkRateLimit(pubkey)) {
      ws.send(JSON.stringify([
        "NOTICE",
        "rate-limited: Too many requests. Please slow down."
      ]));
      return;
    }

    // Process message
    super.handleAuthenticatedMessage(ws, message, connectionId);
  }
}

Security Considerations

Challenge Security

// ✅ Good: Cryptographically secure random
const challenge = randomBytes(32).toString('hex');

// ❌ Bad: Predictable challenge
const challenge = Date.now().toString();

Timestamp Validation

function isTimestampFresh(timestamp, maxAge = 300) {
  const now = Math.floor(Date.now() / 1000);
  const age = now - timestamp;

  // Check if timestamp is recent
  if (age > maxAge) {
    return false;
  }

  // Check if timestamp is not in future (clock skew tolerance)
  if (age < -60) {
    return false;
  }

  return true;
}

Replay Attack Prevention

class ReplayProtection {
  constructor(ttl = 5 * 60 * 1000) {
    this.usedChallenges = new Map();
    this.ttl = ttl;
  }

  markUsed(challenge) {
    this.usedChallenges.set(challenge, Date.now());
    this.cleanup();
  }

  isUsed(challenge) {
    return this.usedChallenges.has(challenge);
  }

  cleanup() {
    const now = Date.now();
    for (const [challenge, timestamp] of this.usedChallenges) {
      if (now - timestamp > this.ttl) {
        this.usedChallenges.delete(challenge);
      }
    }
  }
}

Client Support

Relay Software with NIP-42

  • nostream - Full NIP-42 implementation
  • strfry - Plugin support for authentication
  • nostr-rs-relay - Auth module available
  • rnostr - Built-in authentication

Client Library Support

LibraryNIP-42 SupportAuto-AuthRetry Logic
nostr-tools⚠️ Manual
NDK
nostr-sdk (Rust)
pynostr⚠️ Manual

Check library documentation for implementation details.


Common Questions

Is authentication mandatory?

No. Relays can choose whether to require authentication. Public relays often don’t require it.

Can I connect anonymously?

Depends on the relay. Some relays require authentication, others allow anonymous connections with limited features.

What if I don’t respond to AUTH challenge?

Relay may close connection or allow limited read-only access. Behavior is relay-specific.

Can relays see my private key?

No! You only send signed events. Your private key never leaves your client.

How often do I need to authenticate?

Once per connection. If you disconnect and reconnect, you’ll receive a new challenge.

Can multiple clients use the same identity?

Yes. Each client authenticates independently with the relay.


  • NIP-01 - Basic protocol (event structure, signatures)
  • NIP-11 - Relay information (advertising auth requirements)
  • NIP-65 - Relay lists (choosing authenticated relays)

Technical Specification

For the complete technical specification, see NIP-42 on GitHub.


Summary

NIP-42 enables relay authentication on Nostr:

Challenge-response mechanism ✅ Kind 22242 AUTH events ✅ Signed authentication with private key ✅ Use cases: Paid relays, access control, rate limiting ✅ Replay protection via unique challenges

AUTH flow:

1. Relay sends: ["AUTH", "<challenge>"]
2. Client responds: ["AUTH", {kind: 22242, tags: [["relay", "..."], ["challenge", "..."]], ...}]
3. Relay verifies signature and grants access

AUTH event structure:

{
  "kind": 22242,
  "tags": [
    ["relay", "wss://relay.example.com"],
    ["challenge", "d5f8a9c2..."]
  ],
  "content": ""
}

Status: Draft - implemented by major relay software.

Best practice: Use secure random challenges, validate timestamps, prevent replay attacks.


Next Steps:


Last updated: January 2024 Official specification: GitHub

Client Support

This NIP is supported by the following clients:

nostream strfry nostr-rs-relay rnostr
View all clients →

Related NIPs

NIP-01 NIP-11 NIP-65
← Browse All NIPs