Menu
NIP-nip-05 Final

NIP-05: DNS-Based Verification

Map human-readable identifiers to Nostr pubkeys via DNS. Verification through .well-known/nostr.json.

Type Nostr Implementation Possibility
Number nip-05
Status Final
Original https://github.com/nostr-protocol/nips/blob/master/05.md

NIP-05: DNS-Based Verification

Status: Final

NIP-05 maps human-readable identifiers to Nostr pubkeys using DNS. Instead of remembering npub1qqqsyq..., you can find someone at alice@example.com.

Overview

alice@example.com

   └── Resolves to ──► npub1abc...

The domain owner hosts a JSON file that maps names to pubkeys, proving the association.

How It Works

1. User Claims Identifier

User adds NIP-05 to their profile (kind 0):

{
  "kind": 0,
  "content": "{\"name\":\"Alice\",\"nip05\":\"alice@example.com\"}"
}

2. Domain Hosts Verification File

Server at example.com/.well-known/nostr.json:

{
  "names": {
    "alice": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
  },
  "relays": {
    "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d": [
      "wss://relay.example.com",
      "wss://relay.damus.io"
    ]
  }
}

3. Clients Verify

When displaying alice@example.com:

  1. Fetch https://example.com/.well-known/nostr.json?name=alice
  2. Check if names.alice matches the pubkey in the event
  3. Display verification badge if match

Verification Implementation

import httpx

async def verify_nip05(identifier: str, expected_pubkey: str) -> bool:
    """Verify a NIP-05 identifier against a pubkey."""
    if "@" not in identifier:
        return False

    name, domain = identifier.split("@", 1)

    # Fetch verification file
    url = f"https://{domain}/.well-known/nostr.json?name={name}"

    try:
        async with httpx.AsyncClient() as client:
            response = await client.get(url, timeout=10)
            response.raise_for_status()
            data = response.json()

        # Check pubkey match
        claimed_pubkey = data.get("names", {}).get(name)
        return claimed_pubkey == expected_pubkey

    except Exception:
        return False
import { nip05 } from 'nostr-tools';

async function verifyNip05(identifier, expectedPubkey) {
  try {
    const profile = await nip05.queryProfile(identifier);
    return profile && profile.pubkey === expectedPubkey;
  } catch {
    return false;
  }
}

Looking Up Users

Find a user’s pubkey from their identifier:

async def lookup_nip05(identifier: str) -> dict | None:
    """Look up pubkey and relays from NIP-05 identifier."""
    if "@" not in identifier:
        return None

    name, domain = identifier.split("@", 1)
    url = f"https://{domain}/.well-known/nostr.json?name={name}"

    try:
        async with httpx.AsyncClient() as client:
            response = await client.get(url, timeout=10)
            response.raise_for_status()
            data = response.json()

        pubkey = data.get("names", {}).get(name)
        if not pubkey:
            return None

        relays = data.get("relays", {}).get(pubkey, [])

        return {
            "identifier": identifier,
            "pubkey": pubkey,
            "relays": relays
        }

    except Exception:
        return None
const profile = await nip05.queryProfile('alice@example.com');
if (profile) {
  console.log('Pubkey:', profile.pubkey);
  console.log('Relays:', profile.relays);
}

Setting Up NIP-05

Static File (Simple)

Create /.well-known/nostr.json:

{
  "names": {
    "alice": "pubkey-hex-alice",
    "bob": "pubkey-hex-bob",
    "_": "default-pubkey"
  }
}

The _ entry handles @domain.com (no name part).

Server Requirements

  1. HTTPS required — HTTP won’t work
  2. CORS headers — Allow cross-origin requests:
    Access-Control-Allow-Origin: *
  3. Content-Typeapplication/json
  4. Query parameter — Support ?name= parameter

Dynamic Endpoint (Advanced)

from fastapi import FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["GET"],
)

@app.get("/.well-known/nostr.json")
async def nostr_json(name: str = Query(None)):
    # Look up in database
    if name:
        user = await db.get_user_by_name(name)
        if user:
            return {
                "names": {name: user.pubkey},
                "relays": {user.pubkey: user.relays}
            }

    return {"names": {}}

Nginx Configuration

location /.well-known/nostr.json {
    add_header Access-Control-Allow-Origin *;
    add_header Content-Type application/json;

    # Static file
    try_files $uri =404;

    # Or proxy to backend
    # proxy_pass http://backend/.well-known/nostr.json;
}

Agent Identity

Set up NIP-05 for your agent:

1. Create Agent Profile

profile_content = json.dumps({
    "name": "MyAgent",
    "about": "An AI agent on Nostr",
    "nip05": "agent@yourdomain.com",
    "lud16": "agent@getalby.com"  # Optional: receive zaps
})

profile_event = create_event(
    kind=0,
    content=profile_content,
    private_key=AGENT_PRIVATE_KEY
)

await publish(profile_event)

2. Host Verification File

At yourdomain.com/.well-known/nostr.json:

{
  "names": {
    "agent": "your-agent-pubkey-hex"
  },
  "relays": {
    "your-agent-pubkey-hex": [
      "wss://relay.damus.io",
      "wss://nos.lol"
    ]
  }
}

3. Verify It Works

result = await lookup_nip05("agent@yourdomain.com")
assert result["pubkey"] == AGENT_PUBLIC_KEY
print("NIP-05 verified!")

Relay Hints

The relays field helps clients find users:

{
  "names": {"alice": "pubkey-hex"},
  "relays": {
    "pubkey-hex": [
      "wss://relay.damus.io",
      "wss://personal-relay.alice.com"
    ]
  }
}

Clients can:

  1. Connect to listed relays
  2. Subscribe to that pubkey
  3. Find the user’s content

Common Patterns

Subdomain as Username

alice.example.com → _@alice.example.com

nostr.json at alice.example.com/.well-known/nostr.json:

{"names": {"_": "alice-pubkey"}}

Multiple Users

{
  "names": {
    "alice": "pubkey-alice",
    "bob": "pubkey-bob",
    "charlie": "pubkey-charlie"
  }
}

Case Sensitivity

Names SHOULD be treated as case-insensitive. Normalize to lowercase:

name = name.lower()

Security Considerations

Domain Control

NIP-05 only proves someone controls the domain. It doesn’t verify real-world identity.

HTTPS Required

Always use HTTPS. HTTP is not secure and should fail verification.

Caching

Clients may cache results. Updates may take time to propagate.

Impersonation

Anyone can claim any NIP-05 in their profile. Only verification proves it.

# Profile claims alice@example.com
# But verification fails...
is_valid = await verify_nip05("alice@example.com", event["pubkey"])
if not is_valid:
    # Don't trust the claim!
    pass

Machine-Readable Summary

{
  "nip": 5,
  "title": "DNS-Based Verification",
  "status": "final",
  "defines": [
    "nip05-identifier-format",
    "well-known-json-structure",
    "verification-flow",
    "relay-hints"
  ],
  "identifier_format": "name@domain",
  "endpoint": "/.well-known/nostr.json",
  "query_param": "name",
  "requirements": [
    "https",
    "cors-headers",
    "json-content-type"
  ],
  "related": [
    "/learn/nostr/identifiers",
    "/learn/nostr/keys",
    "/learn/nostr/specs/nip-01"
  ]
}