This primer introduces Nostr developers to DIDs (Decentralized Identifiers) and shows how to bridge Nostr's social protocol with the broader web identity ecosystem. You'll go from zero to production-ready DID integration in incremental steps, with immediate wins at each stage.
This primer is designed for developers already familiar with Nostr who want to understand and implement DID-Nostr integration. Each section builds on the previous, with working code examples you can test immediately.
Let's start with immediate gratification. If you have a Nostr pubkey, you already have a DID:
// Your Nostr pubkey (64-character hex) const pubkey = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"; // Your DID is just: const did = `did:nostr:${pubkey}`; // → "did:nostr:32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" console.log("🎉 You have a DID:", did);
That's it! You now have a globally unique, web-compatible identifier. No registration, no servers, no fees. Just your existing Nostr key in a new format.
You know Nostr events. DIDs use "DID Documents" - think of them as persistent, structured profiles that web services can discover and trust.
{ "kind": 0, "created_at": 1737906600, "content": "{\"name\":\"Alice\",\"about\":\"Building nostr\",\"picture\":\"https://...\"}", "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", "sig": "..." }
{ "@context": ["https://w3id.org/did", "https://w3id.org/nostr/context"], "id": "did:nostr:32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", "verificationMethod": [{ "id": "#key1", "type": "Multikey", "controller": "did:nostr:32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", "publicKeyMultibase": "fe70102..." }], "authentication": ["#key1"], "profile": { "name": "Alice", "about": "Building nostr", "picture": "https://...", "timestamp": 1737906600 } }
Aspect | Nostr Event | DID Document |
---|---|---|
Audience | Nostr clients only | Any web service |
Discovery | Query specific relays | HTTP, DNS, standardized |
Verification | Schnorr signature | Cryptographic proof + web standards |
Caching | Client-specific | HTTP caching, CDNs work |
Integration | Custom Nostr code | Standard DID libraries |
Now let's make your DID actually do something. DID Resolution is how you turn a DID string into useful data. Think of it like DNS - turn a name into something actionable.
Perfect for mobile apps, offline scenarios, or when you just need the basics fast.
function resolveDidOffline(did) { // Extract pubkey from DID const pubkey = did.replace('did:nostr:', ''); // Generate minimal document instantly return { "@context": ["https://w3id.org/did", "https://w3id.org/nostr/context"], "id": did, "verificationMethod": [{ "id": `${did}#key1`, "type": "Multikey", "controller": did, "publicKeyMultibase": pubkeyToMultikey(pubkey) }], "authentication": [`${did}#key1`] }; } // Works immediately, no network needed! const didDoc = resolveDidOffline("did:nostr:32e182..."); console.log("✨ Instant DID document:", didDoc);
Best for web apps - uses standard HTTP caching, CDNs, all the web performance tricks you know.
async function resolveDidHttp(did) { const pubkey = did.replace('did:nostr:', ''); const url = `https://example.com/.well-known/did/nostr/${pubkey}.json`; try { const response = await fetch(url); if (response.ok) { // Check if data is fresh const timestamp = response.headers.get('Nostr-Timestamp'); const age = Date.now()/1000 - parseInt(timestamp); if (age < 3600) { // Fresh within 1 hour return response.json(); } } } catch (e) { console.log("HTTP failed, falling back..."); } // Fallback to offline resolution return resolveDidOffline(did); } // Web-optimized with automatic fallback const didDoc = await resolveDidHttp("did:nostr:32e182...");
When you want the complete social profile with all the metadata.
async function resolveDidEnhanced(did) { // Start with offline base let didDoc = resolveDidOffline(did); try { // Query your favorite relays const relays = ["wss://relay.damus.io", "wss://nos.lol"]; const pubkey = did.replace('did:nostr:', ''); // Get kind 0 profile const profile = await fetchProfileFromRelays(pubkey, relays); if (profile) { didDoc.profile = { name: profile.name, about: profile.about, picture: profile.picture, nip05: profile.nip05, timestamp: profile.created_at }; } // Get relay list (kind 10002) const userRelays = await fetchRelaysFromRelays(pubkey, relays); if (userRelays.length > 0) { didDoc.service = userRelays.map((relay, i) => ({ id: `#relay${i+1}`, type: "Relay", serviceEndpoint: relay })); } } catch (e) { console.log("Enhanced resolution failed, using basic document"); } return didDoc; } // Full-featured with graceful degradation const didDoc = await resolveDidEnhanced("did:nostr:32e182...");
Combine all three for maximum performance and reliability:
async function smartResolve(did) { // 1. Try HTTP first (fastest if cached) try { const httpDoc = await resolveDidHttp(did); if (httpDoc.profile?.timestamp) { const age = Date.now()/1000 - httpDoc.profile.timestamp; if (age < 1800) return httpDoc; // Fresh within 30 min } } catch (e) {} // 2. Try enhanced resolution (full features) try { return await resolveDidEnhanced(did); } catch (e) {} // 3. Fallback to offline (always works) return resolveDidOffline(did); } // This always succeeds, with best possible data const didDoc = await smartResolve("did:nostr:32e182...");
Now let's integrate DIDs into real applications. Here are the most common patterns that work great with existing Nostr apps.
Upgrade your existing profile caching with DID superpowers:
class ProfileManager { async getProfile(pubkey) { // Check local cache first let profile = this.cache.get(pubkey); if (!profile || this.isStale(profile)) { // Try DID resolution for fresh data const did = `did:nostr:${pubkey}`; const didDoc = await smartResolve(did); if (didDoc.profile) { profile = { ...didDoc.profile, source: 'did', cachedAt: Date.now() }; this.cache.set(pubkey, profile); } else { // Fallback to relay query profile = await this.fetchFromRelays(pubkey); } } return profile; } isStale(profile) { const age = (Date.now() - profile.cachedAt) / 1000; return age > 3600; // 1 hour } } // Drop-in upgrade for existing apps const profileManager = new ProfileManager(); const profile = await profileManager.getProfile("32e182...");
Bridge Nostr with other systems seamlessly:
class IdentityBridge { async linkAccounts(nostrPubkey) { const did = `did:nostr:${nostrPubkey}`; const didDoc = await smartResolve(did); const identity = { did, nostr: { pubkey: nostrPubkey, npub: nip19.npubEncode(nostrPubkey) }, profile: didDoc.profile, verificationMethods: didDoc.verificationMethod, // Add other protocols mastodon: didDoc.profile?.nip05 ? await this.resolveMastodon(didDoc.profile.nip05) : null, github: didDoc.service?.find(s => s.type === 'GitHub')?.serviceEndpoint, website: didDoc.service?.find(s => s.type === 'Website')?.serviceEndpoint }; return identity; } } // Universal identity object const identity = await bridge.linkAccounts("32e182..."); console.log("Universal identity:", identity);
Add DID auth to your existing Nostr login:
class NostrDIDAuth { async authenticate(pubkey, signature) { // Standard Nostr auth const validNostr = await this.verifyNostrSignature(pubkey, signature); if (!validNostr) throw new Error("Invalid Nostr signature"); // Enhance with DID context const did = `did:nostr:${pubkey}`; const didDoc = await smartResolve(did); // Return enhanced auth context return { authenticated: true, pubkey, did, profile: didDoc.profile, capabilities: this.extractCapabilities(didDoc), // Standard JWT-compatible claims iss: did, // issuer sub: did, // subject aud: 'your-app', // audience exp: Math.floor(Date.now()/1000) + 3600, // expires iat: Math.floor(Date.now()/1000) // issued at }; } extractCapabilities(didDoc) { const caps = []; if (didDoc.authentication) caps.push('authenticate'); if (didDoc.assertionMethod) caps.push('sign'); if (didDoc.profile) caps.push('profile'); if (didDoc.service) caps.push('services'); return caps; } } // Enhanced auth with web standards compatibility const authResult = await didAuth.authenticate(pubkey, signature); console.log("Enhanced auth context:", authResult);
Time to ship! Here are the production considerations and optimizations that will make your DID integration rock solid.
class ProductionDIDResolver { constructor() { this.cache = new Map(); this.pendingRequests = new Map(); // Prevent duplicate requests } async resolve(did, options = {}) { // Check cache first const cached = this.cache.get(did); if (cached && !this.isExpired(cached, options.maxAge || 3600)) { return cached.data; } // Prevent duplicate requests for same DID if (this.pendingRequests.has(did)) { return this.pendingRequests.get(did); } // Start resolution const promise = this.doResolve(did, options); this.pendingRequests.set(did, promise); try { const result = await promise; // Cache with metadata this.cache.set(did, { data: result, timestamp: Date.now(), source: result._resolveSource || 'unknown' }); return result; } finally { this.pendingRequests.delete(did); } } async doResolve(did, options) { // Layer 1: HTTP resolution (fastest) if (!options.skipHttp) { try { const httpResult = await this.resolveHttp(did); httpResult._resolveSource = 'http'; return httpResult; } catch (e) { console.debug('HTTP resolution failed:', e.message); } } // Layer 2: Enhanced with relays if (!options.skipRelays) { try { const enhanced = await this.resolveEnhanced(did); enhanced._resolveSource = 'relays'; return enhanced; } catch (e) { console.debug('Enhanced resolution failed:', e.message); } } // Layer 3: Offline fallback (always works) const offline = await this.resolveOffline(did); offline._resolveSource = 'offline'; return offline; } isExpired(cached, maxAge) { const age = (Date.now() - cached.timestamp) / 1000; return age > maxAge; } }
class BatchDIDResolver extends ProductionDIDResolver { async resolveBatch(dids, options = {}) { const batchSize = options.batchSize || 10; const results = {}; // Process in batches to avoid overwhelming servers for (let i = 0; i < dids.length; i += batchSize) { const batch = dids.slice(i, i + batchSize); const batchPromises = batch.map(did => this.resolve(did, options).catch(e => ({ error: e.message })) ); const batchResults = await Promise.all(batchPromises); batch.forEach((did, index) => { results[did] = batchResults[index]; }); // Small delay between batches to be nice to servers if (i + batchSize < dids.length) { await new Promise(resolve => setTimeout(resolve, 100)); } } return results; } } // Resolve 100 DIDs efficiently const resolver = new BatchDIDResolver(); const results = await resolver.resolveBatch(arrayOf100DIDs);
class RobustDIDResolver extends BatchDIDResolver { constructor(options = {}) { super(); this.metrics = { resolutions: 0, cacheHits: 0, httpSuccess: 0, relaySuccess: 0, offlineOnly: 0, errors: 0 }; this.onError = options.onError || console.error; this.onMetrics = options.onMetrics || (() => {}); } async resolve(did, options = {}) { const startTime = Date.now(); this.metrics.resolutions++; try { const result = await super.resolve(did, options); // Track success metrics switch (result._resolveSource) { case 'http': this.metrics.httpSuccess++; break; case 'relays': this.metrics.relaySuccess++; break; case 'offline': this.metrics.offlineOnly++; break; } // Emit metrics this.onMetrics({ type: 'resolution_success', did, source: result._resolveSource, duration: Date.now() - startTime, cached: this.cache.has(did) }); return result; } catch (error) { this.metrics.errors++; this.onError({ type: 'resolution_error', did, error: error.message, duration: Date.now() - startTime }); // Always try to return something useful try { const fallback = await this.resolveOffline(did); fallback._resolveSource = 'error_fallback'; return fallback; } catch (fallbackError) { // Last resort: minimal identity return { id: did, "@context": ["https://w3id.org/did"], _resolveSource: 'minimal_fallback' }; } } } getMetrics() { return { ...this.metrics, cacheHitRate: this.metrics.cacheHits / this.metrics.resolutions, httpSuccessRate: this.metrics.httpSuccess / this.metrics.resolutions, errorRate: this.metrics.errors / this.metrics.resolutions }; } } // Production-ready resolver with full monitoring const resolver = new RobustDIDResolver({ onError: (error) => console.error('DID Resolution Error:', error), onMetrics: (metrics) => sendToAnalytics(metrics) });
Ready for advanced patterns? Here's where DIDs really shine - connecting Nostr to the broader identity ecosystem.
Your DID becomes the foundation for verifiable credentials - cryptographically signed claims about identity, achievements, certifications.
// Future: Issue a credential about a Nostr user const credential = { "@context": ["https://www.w3.org/2018/credentials/v1"], "type": ["VerifiableCredential", "NostrContributorCredential"], "issuer": "did:nostr:issuer-pubkey...", "subject": "did:nostr:32e182...", // The user's DID "issuanceDate": "2025-01-27T00:00:00Z", "credentialSubject": { "contributorLevel": "Core Developer", "projectsContributed": ["nostr-protocol", "nips"], "badgeEarned": "nostr-og-2024" }, "proof": { "type": "SchnorrSignature2025", "created": "2025-01-27T00:00:00Z", "verificationMethod": "did:nostr:issuer-pubkey...#key1", "signatureValue": "..." } }; // Verifiable, portable, works across all systems
Bridge Nostr identity into enterprise systems:
class EnterpriseNostIDentity { async authenticateEmployee(nostrPubkey) { const did = `did:nostr:${nostrPubkey}`; const didDoc = await resolver.resolve(did); // Check if employee is verified via company credential const employeeCredential = await this.verifyEmployeeCredential(did); if (!employeeCredential) { throw new Error("Not a verified employee"); } // Create standard SAML/OAuth token with DID context return this.createEnterpriseToken({ subject: did, email: didDoc.profile?.nip05, name: didDoc.profile?.name, department: employeeCredential.department, clearanceLevel: employeeCredential.clearanceLevel, // Standard enterprise claims groups: ['nostr-users', 'verified-employees'], roles: ['user', 'contributor'] }); } }
Copy-paste utilities to get started immediately. All functions are self-contained and work in both Node.js and browser environments.
// Convert pubkey to Multikey format (required for DID documents) function pubkeyToMultikey(pubkeyHex) { // Add even parity byte (0x02) to create compressed pubkey const compressed = '02' + pubkeyHex; // Add multicodec varint for secp256k1 (0xe7, 0x01) const withCodec = 'e701' + compressed; // Encode with multibase base16-lower (f prefix) return 'f' + withCodec; } // Validate DID format function isValidNostrDID(did) { if (!did.startsWith('did:nostr:')) return false; const pubkey = did.replace('did:nostr:', ''); return /^[0-9a-f]{64}$/.test(pubkey); } // Extract pubkey from DID function didToPubkey(did) { if (!isValidNostrDID(did)) throw new Error('Invalid DID format'); return did.replace('did:nostr:', ''); } // Convert between npub and DID formats function npubToDID(npub) { // Requires nostr-tools or similar for nip19.decode const { data } = nip19.decode(npub); return `did:nostr:${data}`; } function didToNpub(did) { const pubkey = didToPubkey(did); return nip19.npubEncode(pubkey); } // Generate timestamp for profiles function getCurrentTimestamp() { return Math.floor(Date.now() / 1000); } // Check if profile data is fresh function isProfileFresh(profile, maxAgeSeconds = 3600) { if (!profile?.timestamp) return false; const age = getCurrentTimestamp() - profile.timestamp; return age < maxAgeSeconds; }
// Build .well-known URL for a DID function buildWellKnownURL(did, domain = 'example.com') { const pubkey = didToPubkey(did); return `https://${domain}/.well-known/did/nostr/${pubkey}.json`; } // Fetch DID document via HTTP with proper error handling async function fetchDIDDocument(did, domain) { const url = buildWellKnownURL(did, domain); try { const response = await fetch(url, { headers: { 'Accept': 'application/json', 'User-Agent': 'DID-Nostr-Resolver/1.0' }, // Reasonable timeouts timeout: 5000 }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } // Check freshness via headers const timestamp = response.headers.get('Nostr-Timestamp'); const lastModified = response.headers.get('Last-Modified'); const didDoc = await response.json(); // Add metadata for caching decisions didDoc._metadata = { fetchedAt: Date.now(), source: 'http', timestamp: timestamp ? parseInt(timestamp) : null, lastModified: lastModified, url: url }; return didDoc; } catch (error) { throw new Error(`HTTP resolution failed: ${error.message}`); } } // Serve DID document via HTTP (for servers) function serveDIDDocument(req, res, didDoc) { const timestamp = didDoc.profile?.timestamp || getCurrentTimestamp(); res.setHeader('Content-Type', 'application/json'); res.setHeader('Nostr-Timestamp', timestamp.toString()); res.setHeader('Last-Modified', new Date(timestamp * 1000).toUTCString()); res.setHeader('Cache-Control', 'public, max-age=3600'); res.setHeader('Access-Control-Allow-Origin', '*'); res.json(didDoc); }
// Simple tests for your DID integration const testDID = "did:nostr:32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"; // Test basic format console.assert(isValidNostrDID(testDID), "DID should be valid"); // Test offline resolution const didDoc = resolveDidOffline(testDID); console.assert(didDoc.id === testDID, "Document ID should match"); // Test utilities const pubkey = didToPubkey(testDID); console.assert(pubkey.length === 64, "Pubkey should be 64 hex chars"); console.log("✅ Basic tests passed");
Step-by-step guide to add DID support to your existing Nostr application without breaking anything. Each step adds value while maintaining compatibility.
Add DID support without changing existing functionality:
// 1. Add DID utilities to your existing codebase import { pubkeyToMultikey, isValidNostrDID, didToPubkey } from './did-utils.js'; // 2. Extend your existing user object class User { constructor(pubkey) { this.pubkey = pubkey; this.npub = nip19.npubEncode(pubkey); this.did = `did:nostr:${pubkey}`; // ← NEW: Add DID } // Your existing methods work unchanged async getProfile() { return await fetchProfileFromRelays(this.pubkey); } // NEW: Optional DID-enhanced profile async getDIDProfile() { try { const didDoc = await smartResolve(this.did); return didDoc.profile || await this.getProfile(); // Fallback } catch (error) { return await this.getProfile(); // Always fallback } } } // 3. Your existing code works unchanged const user = new User("32e182..."); const profile = await user.getProfile(); // Still works // 4. New code can use DID features const enhancedProfile = await user.getDIDProfile(); // Enhanced
Start using DIDs for new features while keeping existing flows:
// 1. Enhance your profile manager class ProfileManager { async getProfile(pubkey, options = {}) { // Check if DID enhancement is requested if (options.useDID) { const did = `did:nostr:${pubkey}`; const didDoc = await smartResolve(did); if (didDoc.profile && isProfileFresh(didDoc.profile)) { return { ...didDoc.profile, source: 'did', enhanced: true }; } } // Fallback to existing relay-based approach return await this.fetchFromRelays(pubkey); } // NEW: Batch profile loading with DIDs async getProfiles(pubkeys) { const dids = pubkeys.map(pk => `did:nostr:${pk}`); const didDocs = await resolver.resolveBatch(dids); return pubkeys.map(pubkey => { const did = `did:nostr:${pubkey}`; const didDoc = didDocs[did]; if (didDoc?.profile && isProfileFresh(didDoc.profile)) { return { pubkey, ...didDoc.profile, source: 'did' }; } else { // Queue for relay fetch return { pubkey, source: 'pending' }; } }); } } // 2. Use in UI components async function ProfileCard({ pubkey }) { const [profile, setProfile] = useState(null); useEffect(() => { // Try DID first, fallback gracefully profileManager.getProfile(pubkey, { useDID: true }) .then(setProfile) .catch(() => { // Fallback to old method return profileManager.getProfile(pubkey, { useDID: false }); }) .then(setProfile); }, [pubkey]); return ( <div className="profile-card"> <img src={profile?.picture} alt={profile?.name || "Unknown"} /> <h3>{profile?.name || truncatePubkey(pubkey)}</h3> <p>{profile?.about}</p> {profile?.source === 'did' && <span className="badge">Fast Load</span>} </div> ); }
Make DIDs the primary identity system with relay fallback:
// 1. DID-first identity system class IdentitySystem { constructor() { this.resolver = new RobustDIDResolver(); this.cache = new IdentityCache(); } async resolveIdentity(identifier) { let did, pubkey; // Handle multiple input formats if (identifier.startsWith('did:nostr:')) { did = identifier; pubkey = didToPubkey(did); } else if (identifier.startsWith('npub')) { pubkey = nip19.decode(identifier).data; did = `did:nostr:${pubkey}`; } else if (/^[0-9a-f]{64}$/.test(identifier)) { pubkey = identifier; did = `did:nostr:${pubkey}`; } else { throw new Error('Invalid identifier format'); } // Resolve with full caching const identity = await this.cache.get(did); if (identity && isProfileFresh(identity.profile)) { return identity; } // Full resolution const didDoc = await this.resolver.resolve(did); const identity = { did, pubkey, npub: nip19.npubEncode(pubkey), profile: didDoc.profile, verificationMethods: didDoc.verificationMethod, services: didDoc.service || [], capabilities: this.extractCapabilities(didDoc), lastUpdated: Date.now() }; await this.cache.set(did, identity); return identity; } extractCapabilities(didDoc) { const caps = new Set(); if (didDoc.authentication) caps.add('authenticate'); if (didDoc.assertionMethod) caps.add('sign'); if (didDoc.keyAgreement) caps.add('encrypt'); if (didDoc.service?.find(s => s.type === 'Relay')) caps.add('nostr'); if (didDoc.profile) caps.add('profile'); return Array.from(caps); } } // 2. Update your main app class NostrApp { constructor() { this.identity = new IdentitySystem(); this.users = new Map(); // Local user cache } async loadUser(identifier) { try { // DID-first approach const identity = await this.identity.resolveIdentity(identifier); const user = { ...identity, // Add app-specific methods async sendNote(content) { // Your existing note sending logic }, async getTimeline() { // Enhanced timeline with DID context const relays = identity.services .filter(s => s.type === 'Relay') .map(s => s.serviceEndpoint); return await fetchTimelineFromRelays(identity.pubkey, relays); } }; this.users.set(identity.did, user); return user; } catch (error) { console.error('Failed to load user identity:', error); throw error; } } } // 3. Your app now works with any identifier format const app = new NostrApp(); // All of these work: const user1 = await app.loadUser("did:nostr:32e182..."); const user2 = await app.loadUser("npub1xt0c5e34g2twm..."); const user3 = await app.loadUser("32e1827635450ebb...");
Add cutting-edge DID features as they become available:
DIDs make your Nostr identity work everywhere, not just in Nostr apps. Think of it like having a driver's license that works in every country - same identity, universal recognition. Plus you get massive performance wins from HTTP caching.
Not at all! DIDs are additive. Your existing code keeps working. You can add DID support gradually, starting with just better profile caching.
Massive improvement. HTTP resolution can be 100x faster than relay queries. Offline resolution is instant. Your users will notice the speed boost immediately.
Nope! DIDs are web standards from W3C (same folks who made HTML). If you know HTTP and JSON, you know 90% of what you need. The crypto part is just your existing Nostr keys.
Graceful degradation. The resolver tries HTTP first, then relays, then offline generation. Something always works.
The timestamp
field tells you when the profile was last updated.
Compare it with your local cache to know if you have the latest data.
Absolutely! Offline resolution works great on mobile. HTTP resolution reduces battery drain from WebSocket connections.
Same as Nostr - profile info is already public. DIDs just make it more accessible. You control what goes in the profile field, same as with Nostr events.
Just static JSON files! Put them at /.well-known/did/nostr/{pubkey}.json
on your web server. Works with GitHub Pages, Netlify, any hosting.
Yes! Add your own fields alongside the standard ones. Just make sure they don't conflict with standard DID fields.
Each Nostr pubkey gets its own DID. If users have multiple keys, they get multiple DIDs. You can link them via service endpoints or credentials.
Nostr doesn't support key rotation, so neither do Nostr DIDs. If someone's key is compromised, they need a new DID (same as a new Nostr identity).
Yes! Put Lightning addresses in the profile field, or create service endpoints for payment methods. Perfect for Lightning address discovery.
Find complete, runnable examples for all the patterns in this guide: