Warning
ALPHA RELEASE — USE AT YOUR OWN RISK
nostr-shard-signer is currently in Alpha and has not undergone a formal security audit.
Because this library manages private key reconstruction in a browser/iframe context, it is vulnerable to frontend supply chain attacks (e.g., if the CDN or iframe host is compromised, keys could be exposed). Do not use this to secure high-value wallets or significant funds.
Security reviews, testing, and pull requests are highly encouraged!
Drop one <script> tag into any web page and your users get a fully-functional window.nostr signer — no browser extension, no nsec copy-paste required.
Two login paths, one API:
| Mode | Who it's for | How it works |
|---|---|---|
| Web3Auth OAuth | Users new to Nostr or who prefer social login | Google, Apple, or X → MPC key is derived and held inside a sandboxed cross-origin iframe. The nsec never leaves that context. |
NIP-46 Bunker (via window.nostr.js) |
Users who already hold their own Nostr key | window.nostr.js connects silently to a NIP-46 bunker (nsec.app, Amber, etc.) over an encrypted relay channel. The parent app sees only signed events — never the key. |
The bridge detects a stored session on load and switches modes transparently. Both expose an identical window.nostr API, so client code is the same regardless of which path the user chose.
Need zero client-side key reconstruction? For maximum custody control, users can point the NIP-46 mode at a self-hosted bunker (e.g. nsecbunker) or use nsec.app as the remote signer — nostr-shard-signer just passes calls through.
| Threat | Mitigation |
|---|---|
| Parent page JS reading your nsec | Key lives only in a cross-origin iframe; Same-Origin Policy makes it unreachable |
| Browser extension / XSS on parent page | Same isolation — the iframe context is physically separate |
| Domain spoofing to steal Web3Auth quota | NIP-33 registry on Nostr validates every (clientId, domain) pair before the iframe renders |
| Rogue iframe injected by attacker | event.source === window.parent check + _parentOrigin derived from document.ancestorOrigins (not URL params) |
| null-origin postMessage attacks | All window.addEventListener("message") handlers reject event.origin === "null" unconditionally |
For users who require zero client-side key reconstruction, use nsec.app or a self-hosted NIP-46 bunker instead.
- Visit the Developer Portal
- Connect your Nostr key (Alby, Amber, or any NIP-07 extension)
- Go to Register clientId tab
- Enter your Web3Auth
clientId(get one free from Web3Auth and whitelisthttps://saintego.github.ioin Project Settings->Domains->Allowlist URLs) - Enter your app's domain (e.g.,
https://myapp.com) - Submit — your domain is now authorized
<script src="https://saintego.github.io/nostr-shard-signer/nostr-bridge.js"></script>// Initialize the bridge (required once)
await NostrBridge.init({
clientId: "YOUR_WEB3AUTH_CLIENT_ID",
bunkerOrigin: "https://saintego.github.io/nostr-shard-signer",
registrarUrl: "https://nostr-shard-registrar.nostr-shard-signer.workers.dev",
});
// Then use standard NIP-07 API — no special bridge calls needed
const pubkey = await window.nostr.getPublicKey();
console.log("User pubkey:", pubkey);
// Sign an event
const event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: "Hello Nostr!",
};
const signed = await window.nostr.signEvent(event);
// Encrypt/decrypt (if needed)
const encrypted = await window.nostr.nip04?.encrypt("target-pubkey", "secret");
const decrypted = await window.nostr.nip04?.decrypt("sender-pubkey", encrypted);That's it. Your app now has:
- ✅ Web3Auth OAuth for new users (Google, Apple, X)
- ✅ NIP-46 bunker support for existing Nostr users (Alby, Amber, nsec.app)
- ✅ Automatic session persistence across page reloads
- ✅ Zero friction — users pick their preferred login path
The bridge only uses the standard NIP-07 API:
window.nostr.getPublicKey()window.nostr.signEvent(event)window.nostr.nip04.encrypt/decrypt()window.nostr.nip44.encrypt/decrypt()
If your app already uses any Nostr extension (Alby, nos2x, etc.), replacing it with this bridge is a drop-in substitute — your code stays the same.
If you need to update your UI when users log in or out, listen for the AUTH_STATE bridge event:
window.addEventListener("message", (e) => {
if (e.data?.type === "AUTH_STATE" && e.origin === "") {
if (e.data.loggedIn) {
console.log("User logged in:", e.data.pubkey);
// Show main app
} else {
console.log("User logged out");
// Show login screen
}
}
});Most apps don't need this. If
window.nostr.getPublicKey()succeeds, the user is logged in. If it throws, they're not — simple as that.
┌─────────────────────────────────────────────┐
│ Parent App (app.example.com) │
│ │
│ <script src="nostr-bridge.js"> │
│ window.nostr ← Proxy object │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ <iframe src="<pages>/signer.html"> │ │
│ │ │ │
│ │ signer.html │ │
│ │ ├─ Web3Auth MPC login UI │ │
│ │ ├─ nsec held in isolated JS context │ │
│ │ └─ Signs/encrypts via nostr-tools │ │
│ └───────────────────────────────────────┘ │
│ ↑↓ postMessage (NIP-46 RPC) │
└─────────────────────────────────────────────┘
↑
NIP-33 registry check
↑
┌─────────────────────────────────────────────┐
│ registrar-worker.js (Cloudflare Worker) │
│ POST /register POST /update (two-phase) │
│ REGISTRY_KV: short-lived mutex (60s TTL) │
│ CHALLENGES_KV: nonces (TTL 5m) │
│ Source of truth: NIP-33 events on relays │
└─────────────────────────────────────────────┘
↑
register/update via UI or curl
↑
┌─────────────────────────────────────────────┐
│ portal/index.html (GitHub Pages) │
│ ├─ nostr-bridge.js → window.nostr │
│ ├─ Connect Nostr key (Alby / NIP-07) │
│ ├─ Register tab: clientId + domain │
│ └─ Update tab: sign nonce (kind 27235) │
└─────────────────────────────────────────────┘
[Login Button] ──login──► [Floating Avatar] ──click──► [Profile Modal]
▲ │
└──────────── close ─────────┘
The iframe resizes its container by sending { type: "RESIZE", state: "button" | "avatar" | "modal" } messages to the parent.
Users click the login button inside the iframe, pick a social provider (Google, Apple, X), and Web3Auth derives a Nostr keypair via MPC. The nsec is reconstructed only inside the cross-origin iframe context and is never serialised to localStorage or sent over the wire.
Unlike storing an nsec on a server, MPC splits the key into threshold shares distributed across independent nodes — no single party, including Web3Auth itself, ever holds the complete key, so a server breach yields only an unusable fragment.
This is the recommended mode for apps whose users may not have a Nostr identity yet.
For users who already control their Nostr key, the bridge optionally loads window.nostr.js (WNJ) — a lightweight NIP-46 client that connects to any remote bunker over Nostr relays.
How the handoff works:
NostrBridge.init()loads the WNJ CDN bundle (ifforceIframe: false).- WNJ shows its own connect UI (bunker URL entry or QR scan for Amber).
- Once the user connects, WNJ stores the bunker session in
localStorageand exposeswindow.nostr. - The bridge detects WNJ's session and switches to
MODE_WNJ: allwindow.nostrcalls are routed to WNJ; the Web3Auth iframe is hidden. - On subsequent page loads the session is restored silently — no UI shown, no modal opened.
Compatible remote signers:
| Signer | Platform | Notes |
|---|---|---|
| nsec.app | Web | Hosted bunker, easy onboarding |
| Amber | Android | Phone acts as bunker via NIP-46 |
| nsecbunker | Self-hosted | Full custody, relay of your choice |
NostrBridge.init() option:
NostrBridge.init({
clientId: "YOUR_WEB3AUTH_CLIENT_ID",
bunkerOrigin: "https://saintego.github.io/nostr-shard-signer",
forceIframe: false, // default — loads WNJ; set true to skip WNJ entirely
});Setting forceIframe: true disables WNJ loading and always uses the Web3Auth iframe, regardless of any stored bunker session.
nostr-shard-signer/
├── nostr-bridge.js # Parent wrapper — injects iframe, proxies window.nostr
├── bunker/ # Vite + React + TypeScript bunker app
│ ├── src/ # App source (compiles → signer.html on GitHub Pages)
│ └── vite.config.ts
├── portal/
│ └── index.html # Developer portal — register/update clientIds via UI
├── registrar/
│ ├── registrar-worker.js # Cloudflare Worker — NIP-33 registry API
│ ├── wrangler.toml # Worker configuration
│ └── package.json
└── .github/
└── workflows/
└── deploy.yml # CI/CD — Pages + Cloudflare Worker on push to main
On every push to main, GitHub Actions publishes three assets to GitHub Pages and deploys the Cloudflare Worker:
| URL | Asset |
|---|---|
https://<user>.github.io/nostr-shard-signer/nostr-bridge.js |
CDN bundle |
https://<user>.github.io/nostr-shard-signer/signer.html |
iframe bunker |
https://<user>.github.io/nostr-shard-signer/portal/ |
Developer registration portal |
The root keypair signs all NIP-33 registry events. Keep the private key secret; only the public key is embedded in signer.html.
node -e "
const {generateSecretKey, getPublicKey} = require('nostr-tools');
const sk = generateSecretKey();
const pk = getPublicKey(sk);
const skHex = Buffer.from(sk).toString('hex');
console.log('ROOT_PRIVATE_KEY_HEX =', skHex);
console.log('ROOT_PUBKEY_HEX =', pk);
"In bunker/src/App.tsx (or the compiled output), replace the placeholder:
const ROOT_PUBKEY_HEX = "__ROOT_PUBKEY_HEX__";
// → your actual hex public key, e.g.:
const ROOT_PUBKEY_HEX = "a3b2...";Optionally adjust:
REGISTRY_RELAYS— array of relays that host your NIP-33 eventsPUBLISH_RELAYS— relays to broadcast profile updates to
The bunker is a Vite + React + TypeScript app. Build it with:
cd bunker && npm install && npm run buildThe compiled output is published to GitHub Pages as signer.html by the deploy workflow.
cd registrar
npm install
wrangler kv:namespace create "REGISTRY_KV"
# → Copy the returned id into wrangler.toml binding = "REGISTRY_KV"
wrangler kv:namespace create "CHALLENGES_KV"
# → Copy the returned id into wrangler.toml binding = "CHALLENGES_KV"
# Store the private key as a Cloudflare secret — never commit it
wrangler secret put ROOT_PRIVATE_KEY_HEX
# → Paste ROOT_PRIVATE_KEY_HEX when promptedIn Settings → Secrets → Actions, add:
| Secret | Value |
|---|---|
CLOUDFLARE_API_TOKEN |
Cloudflare API token with Workers:Edit permission |
CLOUDFLARE_ACCOUNT_ID |
Your Cloudflare account ID |
In Settings → Pages, set source to GitHub Actions.
In portal/index.html, replace the placeholder:
const REGISTRAR_URL = "https://nostr-shard-registrar.__ACCOUNT__.workers.dev";After the one-time setup, every push to main automatically:
- Publishes
nostr-bridge.js,signer.html, andportal/index.htmlto GitHub Pages - Deploys
registrar-worker.jsto Cloudflare Workers
https://<user>.github.io/nostr-shard-signer/nostr-bridge.js ← CDN bundle
https://<user>.github.io/nostr-shard-signer/signer.html ← iframe bunker
https://<user>.github.io/nostr-shard-signer/portal/ ← developer portal
Option A — Portal UI (recommended)
Open https://<user>.github.io/nostr-shard-signer/portal/, connect your Nostr key (Alby or any NIP-07 extension), fill in your clientId and domain, and click Register.
Option B — curl
curl -X POST https://nostr-shard-registrar.<account>.workers.dev/register \
-H "Content-Type: application/json" \
-d '{
"clientId": "YOUR_WEB3AUTH_CLIENT_ID",
"npub": "npub1yourpublickey...",
"domain": "https://yourapp.com"
}'A successful response returns the Nostr event ID of the published NIP-33 record:
{ "ok": true, "event": "abc123...", "published": 3, "total": 3 }bunkerOrigin is required — NostrBridge throws if it is omitted.
<!-- In your parent app's <head> -->
<script src="https://cdn.yourdomain.com/nostr-bridge.js"></script>
<script>
NostrBridge.init({
clientId: "YOUR_WEB3AUTH_CLIENT_ID",
bunkerOrigin: "https://bunker.yourdomain.com", // required
layout: "floating", // or "in-place"
buttonSize: "standard", // or "large_social_grid"
forceIframe: false, // true = skip native extension check
});
</script>window.nostr is injected synchronously, so calls made before the iframe loads are automatically queued.
// Works immediately — queued if iframe hasn't reported AUTH_STATE yet
const pubkey = await window.nostr.getPublicKey();
const signed = await window.nostr.signEvent({
kind: 1,
content: "Hello Nostr!",
tags: [],
created_at: Math.floor(Date.now() / 1000),
});Option A — Portal UI (recommended)
Open the developer portal, switch to the Update Domains tab, enter your clientId, add/remove domains, and click Sign & Update. The portal fetches the nonce, prompts your Nostr extension to sign it (kind 27235), and submits the proof — no terminal needed.
Option B — curl
# Phase 1: request a nonce
NONCE=$(curl -s -X POST https://<worker>/update \
-H "Content-Type: application/json" \
-d '{"clientId":"YOUR_CLIENT_ID","npub":"npub1..."}' | jq -r .nonce)
# Phase 2: sign the nonce with your Nostr key (using nak or any NIP-01 signer)
SIGNED=$(nak event --kind 27235 --content "$NONCE" --sec <your_nsec>)
# Submit the update with the signed event
curl -X POST https://<worker>/update \
-H "Content-Type: application/json" \
-d "{
\"clientId\": \"YOUR_CLIENT_ID\",
\"nonce\": \"$NONCE\",
\"domains\": [\"https://yourapp.com\", \"https://staging.yourapp.com\"],
\"signedEvent\": $SIGNED
}"The nonce is stored in CHALLENGES_KV with a 5-minute TTL and deleted after use.
| Message | When sent |
|---|---|
{ type: "AUTH_STATE", loggedIn: bool, pubkey: string|null } |
On iframe load (passive session check) |
{ type: "AUTH_SUCCESS", pubkey: string } |
After user completes OAuth flow |
{ type: "RESIZE", state: "button"|"avatar"|"modal" } |
On every view transition |
Supported methods: get_public_key, sign_event, nip04_encrypt, nip04_decrypt, nip44_encrypt, nip44_decrypt
| Action | Behaviour |
|---|---|
| Kind 1 (notes), Kind 7 (reactions) | Auto-approved |
| Kind 0 (profile), Kind 9734 (zaps), Kind 4/44 (DMs), unknown kinds | Confirmation modal |
| Any decrypt operation | Confirmation modal |
Claim a new clientId. First-come, first-served. The same npub can add more domains idempotently.
// Request
{ "clientId": "string", "npub": "npub1...", "domain": "https://app.example.com" }
// 201 Created
{ "ok": true, "event": "<nostr_event_id>", "published": 3, "total": 3 }
// 409 Conflict — already claimed by different npub
{ "error": "clientId is already claimed by a different npub" }Phase 1 — request a nonce (omit nonce and signedEvent):
// Request
{ "clientId": "string", "npub": "npub1..." }
// Response
{ "ok": true, "nonce": "<64 hex chars>", "expiresIn": 300 }Phase 2 — submit the update with proof:
// Request
{
"clientId": "string",
"nonce": "<64 hex chars from Phase 1>",
"domains": ["https://app.example.com"],
"signedEvent": { /* signed NIP-01 kind:27235 event with content = nonce */ }
}
// Response
{ "ok": true, "event": "<nostr_event_id>", "domains": [...], "published": 3, "total": 3 }Nonces are stored with a 5-minute TTL and consumed on first use.
One-time setup
- Replace
__ROOT_PUBKEY_HEX__insigner.html - Run
wrangler secret put ROOT_PRIVATE_KEY_HEX— never commit the private key - Create both KV namespaces (
REGISTRY_KV,CHALLENGES_KV) and updatewrangler.tomlIDs - Add
CLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_IDas GitHub repository secrets - Enable GitHub Pages (Settings → Pages → Source: GitHub Actions)
- Update
REGISTRAR_URLinportal/index.htmlto your deployed Worker URL
After first deploy
- Pass
bunkerOrigin(your Pagessigner.htmlURL) toNostrBridge.init()— it is required - Add SRI hashes to CDN
<script>tags insigner.html - Restrict
Access-Control-Allow-Origininregistrar-worker.jsto your admin origins - Configure
REGISTRY_RELAYSinsigner.htmlto relays you control or trust - Set up Cloudflare Rate Limiting rules on the registrar endpoints
- Configure your Web3Auth dashboard verifiers to match your
clientId
If you are contributing or using this in production, please be aware of the following architectural quirks currently being worked on:
- Extension Override Lockout: If a user has a NIP-07 extension installed (like Alby), the bridge detects it and completely skips the Web3Auth iframe. Currently, if an Alby user wants to log in with Google, there is no UI toggle to let them do so.
- Workaround: Parent apps must manually implement two login buttons and call
NostrBridge.init({ forceIframe: true })to bypass the extension check.
- Workaround: Parent apps must manually implement two login buttons and call
- Logout State Syncing: When a user logs out from inside the iframe or WNJ, the internal
window.nostrproxy is destroyed, but the parent app's UI is not automatically notified. The app will visually appear "logged in" until it tries to sign an event and fails.- TODO: The bridge needs to fire a native
window.dispatchEvent(new CustomEvent('nostr:logout'))that the parent app can listen to.
- TODO: The bridge needs to fire a native
- Web3Auth Modal UI Jitter: When the Web3Auth OAuth modal "unfolds" inside the iframe, it requires a lot of screen space. Because the iframe container is initially sized strictly to the login button, the Web3Auth UI can get cut off or squish the background.
- TODO: The iframe must dispatch the
RESIZE(modal state) message to expand to100vw/100vhbefore the Web3Auth UI attempts to render.
- TODO: The iframe must dispatch the
MIT