# Nostr Login Implementation (NIP-07 + NIP-98) This document describes a working Nostr authentication system for a Next.js frontend that talks to a backend API. It uses **NIP-07** (browser extension / remote signer like Amber) for key management and **NIP-98** for HTTP authentication. The user never shares their private key with the app. All signing is done by the external signer (browser extension on desktop, Amber on Android, etc.) via the `window.nostr` interface. ## Dependencies ``` nostr-tools ^2.19.3 # NIP-98 token generation axios ^1.12.2 # HTTP client (can be replaced with fetch) js-cookie ^3.0.5 # Cookie management for JWT persistence ``` ## How It Works 1. User clicks "Login with Nostr" 2. App calls `window.nostr.getPublicKey()` to get the user's pubkey from their signer 3. App builds a NIP-98 signed HTTP auth event (kind 27235) and sends `POST /auth/nostr/session` 4. Backend verifies the NIP-98 signature, returns a JWT 5. JWT is stored in localStorage + cookie, used for subsequent API calls On Android with Amber: Amber registers as the `window.nostr` provider in mobile browsers, so the same code works seamlessly - the user just approves the signing request in Amber. --- ## File 1: TypeScript Global Types Add this to your global type declarations so TypeScript knows about `window.nostr`: ```ts // global.d.ts type NostrProvider = { getPublicKey: () => Promise; signEvent: (event: unknown) => Promise; }; declare global { interface Window { nostr?: NostrProvider; } } ``` --- ## File 2: Storage Layer Manages localStorage persistence of the Nostr pubkey and JWT. Includes automatic expiration checking. ```ts // lib/nostr/storage.ts const NOSTR_PUBKEY_KEY = 'nostr:pubkey'; const NOSTR_TOKEN_KEY = 'nostr:token'; const isBrowser = () => typeof globalThis !== 'undefined'; const decodeTokenPayload = (token: string) => { const [, payload] = token.split('.'); if (!payload) return null; try { if (typeof atob === 'function') { return JSON.parse(atob(payload)); } if (typeof Buffer !== 'undefined') { return JSON.parse(Buffer.from(payload, 'base64').toString()); } } catch { return null; } return null; }; const isExpired = (token?: string | null) => { if (!token || !isBrowser()) return false; const payload = decodeTokenPayload(token); if (!payload?.exp) return false; const now = Math.floor(Date.now() / 1000); return payload.exp <= now; }; export const getStoredNostrPubkey = () => { if (!isBrowser()) return null; return globalThis.localStorage.getItem(NOSTR_PUBKEY_KEY); }; export const persistNostrPubkey = (pubkey: string) => { if (!isBrowser()) return; globalThis.localStorage.setItem(NOSTR_PUBKEY_KEY, pubkey); }; export const clearNostrPubkey = () => { if (!isBrowser()) return; globalThis.localStorage.removeItem(NOSTR_PUBKEY_KEY); }; export const getStoredNostrToken = () => { if (!isBrowser()) return; const token = globalThis.localStorage.getItem(NOSTR_TOKEN_KEY); if (isExpired(token)) { globalThis.localStorage.removeItem(NOSTR_TOKEN_KEY); return; } return token ?? undefined; }; export const persistNostrToken = (token: string) => { if (!isBrowser()) return; globalThis.localStorage.setItem(NOSTR_TOKEN_KEY, token); }; export const clearNostrSession = () => { if (!isBrowser()) return; globalThis.localStorage.removeItem(NOSTR_TOKEN_KEY); globalThis.localStorage.removeItem(NOSTR_PUBKEY_KEY); }; export const nostrStorageKeys = { pubkey: NOSTR_PUBKEY_KEY, token: NOSTR_TOKEN_KEY, }; ``` --- ## File 3: fetchWithNostr — NIP-98 HTTP Authentication This is the core helper. It takes a URL + request options (like `fetch`), builds a NIP-98 auth event, signs it via `window.nostr`, and attaches it as headers. Uses axios under the hood but the pattern works with any HTTP client. ```ts // lib/nostr/fetch-with-nostr.ts import axios, { AxiosRequestConfig } from 'axios'; import { nip98 } from 'nostr-tools'; const HTTPAuth = 27_235; // NIP-98 kind number export class MissingNostrProviderError extends Error { constructor() { super('Nostr provider not found. Please install a NIP-07 extension.'); this.name = 'MissingNostrProviderError'; } } type BodyValue = BodyInit | null | undefined | Record; type NostrProvider = { getPublicKey: () => Promise; signEvent: (event: unknown) => Promise; }; const isBrowser = () => typeof globalThis !== 'undefined'; const getNostrProvider = (): NostrProvider | undefined => { if (!isBrowser()) return undefined; return (globalThis as unknown as { nostr?: NostrProvider }).nostr; }; /** * Parse the request body into a form suitable for sending + for NIP-98 payload hashing. */ const parseBody = (body?: BodyValue) => { if (!body) return { bodyToSend: undefined as BodyInit | null | undefined, payloadForToken: undefined as unknown, }; if (typeof body === 'string') { try { const parsed = JSON.parse(body); return { bodyToSend: body, payloadForToken: parsed }; } catch { return { bodyToSend: body, payloadForToken: body }; } } if (typeof FormData !== 'undefined' && body instanceof FormData) return { bodyToSend: body, payloadForToken: undefined }; if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) return { bodyToSend: body, payloadForToken: undefined }; if (typeof Blob !== 'undefined' && body instanceof Blob) return { bodyToSend: body, payloadForToken: undefined }; if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return { bodyToSend: body as BodyInit, payloadForToken: undefined }; if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) return { bodyToSend: body as BodyInit, payloadForToken: undefined }; // Plain object — JSON-serialize it return { bodyToSend: JSON.stringify(body), payloadForToken: body }; }; const encodeBase64 = (value: string) => { if (typeof btoa === 'function') return btoa(value); return Buffer.from(value).toString('base64'); }; /** * Fallback: manually build a NIP-98 token if nostr-tools' nip98.getToken() fails. * Constructs a kind 27235 event with url, method, and optional payload hash tags. */ const buildManualToken = async ( provider: NostrProvider, url: string, method: string, payload?: unknown, ) => { const tags: string[][] = [ ['u', url], ['method', method], ]; if (payload && typeof payload === 'object') { try { tags.push(['payload', nip98.hashPayload(payload)]); } catch { // ignore hashing errors } } const event = { kind: HTTPAuth, tags, content: '', created_at: Math.round(Date.now() / 1000), }; const signedEvent = await provider.signEvent(event); return encodeBase64(JSON.stringify(signedEvent)); }; const resolveUrl = (input: RequestInfo | URL) => { if (typeof input === 'string') { if (input.startsWith('http://') || input.startsWith('https://')) return input; const base = isBrowser() && globalThis.location !== undefined ? globalThis.location.origin : 'http://localhost'; return new URL(input, base).toString(); } if (input instanceof URL) return input.toString(); if (typeof Request !== 'undefined' && input instanceof Request) return input.url; return String(input); }; const getExistingHeader = ( headersInit: HeadersInit | undefined, key: string, ) => { if (!headersInit) return; if (headersInit instanceof Headers) return headersInit.get(key) || headersInit.get(key.toLowerCase()); if (Array.isArray(headersInit)) { const found = headersInit.find( ([k]) => k.toLowerCase() === key.toLowerCase(), ); return found?.[1]; } const record = headersInit as Record; const exact = record[key]; if (exact) return exact; const lower = Object.entries(record).find( ([k]) => k.toLowerCase() === key.toLowerCase(), ); return lower?.[1]; }; type NostrFetchResponse = { ok: boolean; status: number; statusText: string; headers: Headers; json: () => Promise; text: () => Promise; data: any; }; /** * Main entry point: make an HTTP request with NIP-98 auth headers. * * Usage: * const res = await fetchWithNostr('https://api.example.com/auth/nostr/session', { * method: 'POST', * }); * const data = await res.json(); */ export const fetchWithNostr = async ( input: RequestInfo | URL, init?: RequestInit, ): Promise => { const provider = getNostrProvider(); if (!provider) throw new MissingNostrProviderError(); const method = ( init?.method || (typeof Request !== 'undefined' && input instanceof Request ? input.method : 'GET') ).toUpperCase(); const url = resolveUrl(input); const { bodyToSend, payloadForToken } = parseBody(init?.body as BodyValue); // Preserve any existing Authorization header (e.g. Bearer JWT for linking) const existingAuth = getExistingHeader(init?.headers, 'Authorization'); const headers = new Headers(init?.headers ?? {}); if ( payloadForToken && typeof payloadForToken === 'object' && !headers.has('Content-Type') ) { headers.set('Content-Type', 'application/json'); } // Generate the NIP-98 token let nostrToken: string | undefined; try { const signFunction = (event: any) => provider.signEvent(event) as any; nostrToken = await nip98.getToken( url, method, signFunction, false, payloadForToken as Record | undefined, ); } catch { // Fallback to manual token building nostrToken = await buildManualToken( provider, url, method, payloadForToken, ); } // Attach nostr auth headers if (nostrToken) { headers.set('nostr-authorization', `Nostr ${nostrToken}`); headers.set('x-nostr-authorization', `Nostr ${nostrToken}`); } const headerObject: Record = Object.fromEntries( headers.entries(), ); // If there's an existing Bearer token, keep it; otherwise use Nostr token as Authorization if (existingAuth) { headerObject.Authorization = existingAuth; } else if (nostrToken) { headerObject.Authorization = `Nostr ${nostrToken}`; } if (nostrToken) { headerObject['nostr-authorization'] = `Nostr ${nostrToken}`; headerObject['x-nostr-authorization'] = `Nostr ${nostrToken}`; } // Make the request (using axios, but you can swap this for fetch) const axiosConfig: AxiosRequestConfig = { url, method: method as AxiosRequestConfig['method'], headers: headerObject, withCredentials: init?.credentials === 'include', data: bodyToSend as AxiosRequestConfig['data'], }; let response; try { response = await axios(axiosConfig); } catch (error: any) { if (error?.response) { response = error.response; } else { throw error; } } const responseHeaders = new Headers(response.headers as any); return { ok: response.status >= 200 && response.status < 300, status: response.status, statusText: response.statusText, headers: responseHeaders, data: response.data, json: async () => response.data, text: async () => typeof response.data === 'string' ? response.data : JSON.stringify(response.data), }; }; ``` --- ## File 4: useNostrAuth Hook React hook that manages the full auth lifecycle: connect to signer, exchange for JWT session, persist, disconnect. ```ts // hooks/use-nostr-auth.ts import { useCallback, useEffect, useMemo, useState } from 'react'; import { fetchWithNostr, MissingNostrProviderError, } from '@/lib/nostr/fetch-with-nostr'; import { clearNostrSession, getStoredNostrPubkey, getStoredNostrToken, persistNostrPubkey, persistNostrToken, } from '@/lib/nostr/storage'; import Cookies from 'js-cookie'; // Replace with your own API service that accepts setAuthToken / uses Bearer headers import apiService from '@/services/api.service'; type ExchangeResult = { token: string; response: Record; }; export const useNostrAuth = () => { const [pubkey, setPubkey] = useState(null); const [token, setToken] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); // Hydrate from localStorage on mount useEffect(() => { const storedPubkey = getStoredNostrPubkey(); const storedToken = getStoredNostrToken(); if (storedPubkey) setPubkey(storedPubkey); if (storedToken) { setToken(storedToken); apiService.setAuthToken(storedToken); } }, []); /** * Step 1: Ask the signer (extension / Amber) for the user's public key. */ const connect = useCallback(async () => { const provider = typeof globalThis === 'undefined' ? undefined : (globalThis as any).nostr; if (!provider) throw new MissingNostrProviderError(); const publicKey = await provider.getPublicKey(); persistNostrPubkey(publicKey); setPubkey(publicKey); return publicKey; }, []); /** * Clear all Nostr session state. */ const disconnect = useCallback(() => { clearNostrSession(); apiService.setAuthToken(undefined); setPubkey(null); setToken(null); setError(null); }, []); /** * Step 2: Send a NIP-98 signed POST to your backend's session endpoint. * The backend verifies the signature, creates a user/session, returns a JWT. */ const exchangeForSession = useCallback(async (): Promise => { setIsLoading(true); setError(null); try { if (!pubkey) await connect(); const response = await fetchWithNostr( `${process.env.NEXT_PUBLIC_API_URL}/auth/nostr/session`, { method: 'POST' }, ); if (!response.ok) { throw new Error('Unable to create Nostr session.'); } const payload = (await response.json()) as Record; // Backend may return the JWT under different field names const newToken = (payload?.token as string | undefined) || (payload?.jwt as string | undefined) || (payload?.accessToken as string | undefined) || (payload?.idToken as string | undefined); if (!newToken) { throw new Error('Nostr session did not return a token.'); } persistNostrToken(newToken); Cookies.set('accessToken', newToken); apiService.setAuthToken(newToken); setToken(newToken); return { token: newToken, response: payload }; } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : 'Unable to connect to Nostr provider.'; setError(message); throw caughtError; } finally { setIsLoading(false); } }, [connect, pubkey]); const isConnected = useMemo(() => Boolean(pubkey), [pubkey]); return { pubkey, token, isConnected, isLoading, error, connect, disconnect, exchangeForSession, fetchWithNostr, }; }; ``` --- ## File 5: Auth Service — Nostr-Specific Endpoints These are the Nostr-related methods on the auth service for linking/unlinking a pubkey to an existing user account, and the session endpoint for pure Nostr login. ```ts // services/auth.service.ts (Nostr-relevant methods only) import { fetchWithNostr } from '@/lib/nostr/fetch-with-nostr'; const API_URL = process.env.NEXT_PUBLIC_API_URL; /** * Link a Nostr pubkey to an already-authenticated user account. * Sends both the user's existing JWT (Bearer) AND a NIP-98 signature. */ async function linkNostr(pubkey: string, jwt?: string) { try { const response = await fetchWithNostr(`${API_URL}/auth/nostr/link`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), }, body: JSON.stringify({ pubkey }), }); if (!response.ok) { const payload = await response.json().catch(() => ({})); return { ok: false, error: payload?.message || 'Failed to link Nostr key' }; } return { ok: true, data: response.data }; } catch (error: any) { return { ok: false, error: error?.message || 'Failed to link Nostr key' }; } } /** * Remove the linked Nostr pubkey from the current user. */ async function unlinkNostr() { // Uses your normal authenticated API service (Bearer JWT) return await apiService.post('auth/nostr/unlink', {}); } ``` --- ## File 6: Auth Context Integration How the Nostr token integrates with your app's overall auth state. Key pattern: on app init, check for a stored Nostr token first, try to hydrate the user from it, fall back to other auth methods. ```ts // In your auth context / provider: import { clearNostrSession, getStoredNostrToken } from '@/lib/nostr/storage'; // During token resolution (e.g. in setToken or getToken): async function resolveToken() { let token: string | undefined; // 1. Try your primary auth method first (e.g. Cognito/Amplify/OAuth) // token = await getPrimaryAuthToken(); // 2. Fall back to stored Nostr token if (!token) { const nostrToken = getStoredNostrToken(); if (nostrToken) { // Decode and check expiration const exp = decodeTokenExpiration(nostrToken); // your JWT decode helper const now = Math.floor(Date.now() / 1000); if (!exp || exp > now) { token = nostrToken; } else { clearNostrSession(); } } } return token; } // During app initialization: async function initializeAuth() { const nostrToken = getStoredNostrToken(); if (nostrToken) { apiService.setAuthToken(nostrToken); const user = await authService.getMe(); if (user) { setUser(user); setIsLoggedIn(true); return; // Nostr session is valid, done } // Token was invalid/expired clearNostrSession(); } // ... fall through to other auth methods } // During logout: function logout() { clearNostrSession(); // ... clear other auth state, cookies, etc. } ``` --- ## Backend API Contract Your backend needs these endpoints: ### `POST /auth/nostr/session` - **Auth**: NIP-98 signature only (no Bearer token needed) - **Headers**: `Authorization: Nostr ` or `nostr-authorization: Nostr ` - **Backend verifies**: the NIP-98 event signature, that `kind === 27235`, the `u` tag matches the request URL, the `method` tag matches, and `created_at` is recent (within ~60s) - **Returns**: `{ token: "", ... }` — a JWT with `sub` set to the user's pubkey ### `POST /auth/nostr/link` - **Auth**: Both Bearer JWT (existing user session) AND NIP-98 signature - **Body**: `{ pubkey: "" }` - **Purpose**: Bind a Nostr pubkey to an existing user account (so they can log in with Nostr next time) ### `POST /auth/nostr/unlink` - **Auth**: Bearer JWT only - **Purpose**: Remove linked Nostr pubkey from the user's account ### `GET /auth/me` - **Auth**: Bearer JWT (works with both traditional and Nostr-issued JWTs) - **Returns**: User object (include `nostrPubkey` field if linked) --- ## UI Components Needed These are minimal — implement them in your own design system: 1. **Provider Detector** — Check `window.nostr` exists on mount, show "detected" / "not detected" status. Link to extension install pages if not found. 2. **Login Button** — Single button that calls: ```ts await connect(); // gets pubkey from signer await exchangeForSession(); // NIP-98 signed request -> JWT const user = await getMe(); // fetch user profile with JWT // redirect to app ``` 3. **Status Banner** (optional) — Show connected pubkey (shortened) and a disconnect button. 4. **Settings: Link/Unlink** (optional) — For users who logged in another way but want to add Nostr as an alternative login method. --- ## Key Concepts - **NIP-07**: Standard interface for browser extensions / remote signers. Exposes `window.nostr.getPublicKey()` and `window.nostr.signEvent(event)`. Amber on Android implements this same interface. - **NIP-98**: HTTP Auth using Nostr events. A kind `27235` event signed by the user, containing the request URL and method in tags. Sent as a base64-encoded header. - **The private key never touches your app.** All signing happens in the external signer. - **`nostr-tools`** does the heavy lifting for NIP-98 token generation. The manual fallback (`buildManualToken`) handles edge cases where `nip98.getToken()` fails.