The premise of Signet is that you can store Nostr private keys (nsecs), use them remotely under certain policies, but these keys can never be exfiltrated from the bunker. All communication with Signet happens through encrypted, ephemeral Nostr events following the NIP-46 protocol.
Within Signet there are two distinct sets of keys:
The keys that users want to sign with. These keys are stored encrypted with a passphrase using AES-256-GCM with authenticated encryption. The encryption key is derived using PBKDF2 with 600,000 iterations (per NIST SP 800-132 recommendations). Every time you start Signet, you must enter the passphrase to decrypt it.
Without this passphrase, keys cannot be used. The authenticated encryption ensures that any tampering with the encrypted data is detected.
Signet generates its own private key, which is used for NIP-46 bunker communication. If this key is compromised, no user key material is at risk.
Administration is performed via the web UI or Android app, both of which require JWT authentication. The UI should be secured via network-level access control through VPN/WireGuard/Tailscale, firewall rules, or reverse proxy authentication. For emergency situations when you can't access the UI, the kill switch allows remote control via Nostr DMs.
We recommend running Signet on a locally trusted machine behind a VPN.
Signet listens on configured relays, specified in signet.json, for NIP-46 requests from applications attempting to use the target keys.
The REST API provides management functionality for the web dashboard. It implements multiple security layers:
All sensitive endpoints require JWT (JSON Web Token) authentication:
- Tokens are signed using HMAC-SHA256 with a 256-bit secret (
jwtSecretin config) - Tokens expire after 7 days
- Tokens are transmitted via HTTP-only, secure, same-site cookies
Protected endpoints include:
GET /connection- Bunker connection infoGET /keys- List all keysPOST /keys- Create new keysPOST /keys/:name/unlock- Unlock an encrypted keyPOST /keys/:name/lock- Lock a key (clears decrypted material from memory)DELETE /keys/:name- Delete a keyGET /apps- List connected applicationsPOST /apps/:id/revoke- Revoke application accessPOST /apps/:id/suspend- Suspend an applicationPOST /apps/:id/unsuspend- Resume a suspended applicationPATCH /apps/:id- Update application settingsGET /requests- List authorization requestsPOST /requests/:id- Approve a requestDELETE /requests/:id- Deny a requestPOST /requests/batch- Batch approve multiple requestsGET /events- Server-sent events stream for real-time updatesGET /dashboard- Dashboard statisticsGET /admin/activity- Admin activity audit logGET /health- Daemon health statusGET /csrf-token- Obtain CSRF token for state-changing requests
CORS is restricted to explicitly configured origins:
- Only origins listed in
allowedOriginscan make cross-origin requests - Credentials (cookies) are only sent to allowed origins
- Wildcard origins are supported but not recommended for production
State-changing API endpoints are protected against Cross-Site Request Forgery using the double-submit cookie pattern:
- Token Generation: Client fetches a CSRF token via
GET /csrf-token - Cookie Storage: Token is set in a non-HttpOnly cookie (
signet_csrf) - Header Submission: Client includes the token in
X-CSRF-Tokenheader for state-changing requests - Validation: Server compares cookie and header using timing-safe comparison
Protected methods: POST, PUT, DELETE, PATCH
Bearer Token Exemption: API clients using Bearer token authentication (Authorization: Bearer <token>) are exempt from CSRF protection. This is secure because CSRF attacks exploit the browser's automatic cookie sending behavior, which doesn't apply to Bearer tokens that must be explicitly included by the client.
The following endpoints require CSRF tokens:
POST /keys- Create new keysPOST /keys/:name/unlock- Unlock encrypted keysPOST /keys/:name/lock- Lock keysDELETE /keys/:name- Delete keysPOST /apps/:id/revoke- Revoke application accessPOST /apps/:id/suspend- Suspend applicationsPOST /apps/:id/unsuspend- Resume applicationsPATCH /apps/:id- Update application settingsPOST /requests/:id- Approve requestsDELETE /requests/:id- Deny requestsPOST /requests/batch- Batch approve requests
Sensitive endpoints are rate-limited to prevent brute-force attacks:
- 10 requests per minute per IP address
- 1-minute lockout after exceeding the limit
- Rate limits apply to:
- Request approval (
POST /requests/:id) - Key management (
POST /keys,DELETE /keys/:name) - Batch operations (
POST /requests/batch)
- Request approval (
- Callback URLs are validated to prevent XSS (only
http://andhttps://allowed) - Error messages are HTML-escaped before rendering
- JSON parsing uses safe defaults
User keys are encrypted using:
- Algorithm: AES-256-GCM (authenticated encryption)
- Key derivation: PBKDF2-HMAC-SHA256
- Iterations: 600,000
- Salt: 16 bytes, randomly generated per key
- IV/Nonce: 12 bytes, randomly generated per encryption
- Auth tag: 16 bytes (automatically verified on decryption)
The encrypted format includes a version byte for future compatibility. Legacy keys for nsecbunkerd users are encrypted with AES-256-CBC and should be automatically detected and can be decrypted.
All secrets (JWT secret, admin secret) are generated using Node.js crypto.randomBytes():
- Length: 32 bytes (256 bits)
- Encoding: Hexadecimal (64 characters)
When sharing a bunker URI to connect a new application, Signet generates one-time tokens instead of exposing the persistent admin.secret.
- Token Generation: When you click "Generate bunker URI" in the UI (web or Android), Signet creates a fresh 32-byte random token
- Short Expiry: Tokens expire after 5 minutes
- Single Use: Tokens are atomically redeemed on first use—a second connection attempt with the same token will fail
- Session Persistence: Once a client successfully connects, their pubkey is stored in the database. Future requests are identified by pubkey, not the token
- No persistent secret exposure: The
admin.secretis never shown in the UI - Limited window: Even if a bunker URI is intercepted, the attacker has only 5 minutes to use it
- No replay: Using the same token twice is impossible due to atomic redemption
- Backwards compatible: Existing connections using
admin.secretcontinue to work
- Token storage:
ConnectionTokentable in SQLite withkeyName,token,expiresAt,redeemedAt - Atomic redemption: Uses database
updateManywithredeemedAt: nullcondition to prevent race conditions - Cleanup: Expired tokens are automatically deleted hourly
- Validation order: One-time tokens are checked first, then
admin.secretas fallback
The persistent admin.secret (configured in signet.json) still works for backwards compatibility:
- Clients that already have a bunker URI with
admin.secretcan still connect - If a one-time token is invalid or expired, Signet falls back to checking
admin.secret - New connections via the UI always use one-time tokens
While bunker:// URIs are generated by Signet, nostrconnect:// URIs are generated by the connecting app. This is the reverse flow defined in NIP-46.
- App generates URI: The Nostr app creates a nostrconnect:// URI containing its pubkey, relays, and a one-time secret
- User pastes/scans URI: User enters the URI in Signet (web or Android)
- Signet validates: Parses URI, validates format, checks for duplicates
- User approves: User selects a key and trust level
- Signet responds: Sends NIP-46
connectresponse to the app via the specified relays - App receives ACK: Connection is established
- User-initiated: Connections only happen when the user explicitly pastes/scans a URI
- Trust level required: User must select a trust level before the connection is accepted
- Duplicate detection: Signet rejects connection attempts from already-connected apps
- Per-app relays: Each app can specify its own relays for NIP-46 communication
- No secret storage: The app's one-time secret is used only for the initial handshake
| Component | Required | Description |
|---|---|---|
| Client pubkey | Yes | 64-character hex pubkey of the connecting app |
relay |
Yes | One or more relay URLs for communication |
secret |
Yes | One-time secret for initial handshake |
perms |
No | Permissions the app requests (informational only) |
name |
No | App name suggested by the client |
Apps connected via nostrconnect:// can use their own relays:
- Signet creates NIP-46 subscriptions on the app's specified relays
- Responses are published to both Signet's relays AND the app's relays
- Subscriptions are automatically cleaned up when apps are revoked
Encrypted keys can be locked at any time without restarting the daemon. When a key is locked:
- The decrypted key material is cleared from memory
- All NIP-46 requests for that key are rejected
- The key remains locked until manually unlocked with the passphrase
This allows you to temporarily disable signing for a key without deleting it or stopping the daemon.
Lock sources:
- Web UI (click lock icon in sidebar or Keys page)
- Android app (key detail sheet)
- Kill switch DM commands (
lock <keyname>orlockall keys)
Connected applications can be suspended to temporarily block their access:
- Indefinite suspension: App remains suspended until manually resumed
- Timed suspension: App is automatically resumed after a specified time
Suspended apps receive rejection responses for all NIP-46 requests. This is useful when you suspect an app has been compromised or want to temporarily revoke access without deleting the app's permissions.
Suspension sources:
- Web UI (Apps page)
- Android app (app detail sheet)
- Kill switch DM commands (
suspend <appname>orsuspendall apps)
Signet includes an optional Inactivity Lock feature that automatically triggers a security lockdown if you don't check in within a configurable timeframe.
How It Works:
- Enable Inactivity Lock in Settings (Web UI or Android app)
- Configure the timeframe (1 hour to 30 days, default 7 days)
- The timer counts down continuously
- If the timer expires without a reset, all keys are locked and all apps are suspended
- To recover, you must unlock a key with its passphrase
Timer Reset:
The timer is automatically reset when you:
- Click "Reset Timer" in Settings
- Use the "Lock Now" button in System Status (which triggers panic, then you can recover)
Panic State:
When the timer expires (or you manually trigger "Lock Now"):
- All active keys are immediately locked
- All connected apps are suspended
- The UI shows a lock screen overlay
- Only a valid passphrase can recover the system
Use Cases:
- Travel: If you're unreachable for an extended period, your keys are automatically secured
- Incapacitation: Keys are protected if you can't access the system
- Theft prevention: Even if an attacker gains device access, keys lock after the timeout
Sources:
- Web UI: Settings page, System Status modal (Lock Now button)
- Android app: Settings screen, System Status sheet (Lock Now button)
- Kill switch DM commands:
paniccommand also triggers the same lockdown
For emergency situations when you cannot access the web UI or Android app, Signet supports remote administration via Nostr direct messages.
Capabilities:
- Lock all keys instantly (
panic,lockall,killswitch) - Lock individual keys (
lock <keyname>) - Suspend all apps (
suspendall apps) - Check system status (
status)
Security:
- Only DMs from a pre-configured admin npub are accepted
- Messages are encrypted using NIP-04 or NIP-17
- All commands are logged for audit purposes
See KILLSWITCH.md for setup and command reference.
All administrative actions are logged for security review:
| Event Type | Description |
|---|---|
key_locked |
Key was locked |
key_unlocked |
Key was unlocked |
app_suspended |
App was suspended |
app_unsuspended |
App was resumed |
daemon_started |
Daemon process started |
command_executed |
Kill switch command was received |
status_checked |
Kill switch status query was received |
Each log entry includes:
- Timestamp
- Client source (Signet UI, Signet Android, kill switch)
- Client version and IP address (when available)
- Command and result (for kill switch commands)
Logs are accessible via:
- Web UI: Activity page → Admin tab
- Android app: Activity screen → Admin tab
- API:
GET /admin/activity
- Key exfiltration: Private keys never leave the bunker in plain text
- Unauthorized signing: All signing requests require explicit approval (or policy-based auto-approval)
- Brute-force attacks: Rate limiting and strong key derivation
- CSRF attacks: Double-submit cookie pattern with timing-safe comparison
- XSS attacks: CORS restrictions, input validation, and HTML escaping
- Replay attacks: NIP-46 uses ephemeral encrypted events
- Data tampering: Authenticated encryption detects modifications
- Lost device access: Kill switch allows emergency lockdown via Nostr DMs
- Compromised apps: Instant suspension blocks rogue applications
- Compromised host: If the server running Signet is compromised, an attacker could potentially extract decrypted keys from memory while they are unlocked
- Weak passphrases: The encryption is only as strong as the passphrase used
- Configuration file exposure: The config file contains sensitive data (JWT secret, optionally plaintext keys)
- Web UI access compromise: An attacker with access to the web UI can approve signing requests but not extract user keys
- Use HTTPS: Set
baseUrlto an HTTPS URL and use a reverse proxy (nginx, Caddy) - Restrict origins: Set
allowedOriginsto only your UI domain(s) - Secure the config file: Restrict file permissions (
chmod 600 signet.json) - Use encrypted keys: Always encrypt keys with strong passphrases
- Configure kill switch: Set up remote lockdown capability via KILLSWITCH.md
- Review audit logs: Periodically check the Admin tab for unexpected activity
- Monitor logs: Enable verbose logging and monitor for suspicious activity
- Restrict network access: Use VPN, firewall rules, or reverse proxy authentication to limit access to the web UI
- Regular updates: Keep Signet updated to receive security patches
- See DEPLOYMENT.md: For specific setup guides (Tailscale, reverse proxies, etc.)