BitLogin

login for normies.

A static, relay-backed portable account protocol: a familiar login name and password that unlocks a portable Nostr identity, plus an independent 12-word recovery phrase. Encrypted account material lives on public Nostr relays — no account server, no password-reset email, no database.

This repo implements the protocol from docs/spec.md (v0.6) as a modular system any static site can embed for login and Nostr signing.

Layout

packages/
  core/    @bitlogin/core   — protocol implementation: crypto, capsules, Nostr
                              relay I/O, account flows. Framework- and DOM-free,
                              runs in Node or a browser/worker.
  widget/  @bitlogin/widget — <bitlogin-auth> custom element + a NIP-07-shaped
                              window.nostr provider, backed by a dedicated
                              crypto Web Worker. Built with Vite into a
                              same-origin, self-hosted module graph.
  demo/    @bitlogin/demo   — plain static site with a release-manifest build
                              consuming the widget: a landing/branding page,
                              an integration guide, and an account-manager
                              page (create/login/recover, password rotation,
                              identity export).

Quick start

npm install                       # installs all three workspaces
npm test                          # runs @bitlogin/core's test suite (vitest)
npm run build                     # builds core, then widget
npm run dev:demo                  # assembles + serves the demo at :4173

@bitlogin/demo's npm run build copies the built widget into packages/demo/dist/vendor/bitlogin/ alongside the static HTML — the result in packages/demo/dist/ is a complete, ready-to-host static site.

Embedding BitLogin in your own static site

<script type="module" src="/vendor/bitlogin/bitlogin.js"></script>
<bitlogin-auth
  vault-relays="wss://relay-one.example,wss://relay-two.example,wss://relay-three.example"
  discovery-relays="wss://discovery-one.example,wss://discovery-two.example"
>
</bitlogin-auth>

bitlogin.js and cryptoWorker.js (plus the shared chunk they both import) must be deployed side by side — the main script resolves the worker's URL at runtime relative to its own location, so this works from any path, not just the site root.

The widget is a complete login surface, not just the password flow: behind "More sign-in options" it also offers a NIP-07 signer extension (when one is detected), a NIP-46 remote signer (bunker:// paste or a scannable nostrconnect QR for phone signers like Amber), a passkey (WebAuthn PRF — zero site setup, the passkey deterministically derives the account credential), §SF10 key import, and phrase recovery. The bitlogin-login event reports detail.method ('bitlogin' | 'nip07' | 'nip46') and detail.capabilities, and window.bitlogin.activeMethod() / activeSession() answer session state for every method. See docs/login-methods.md and docs/passkey-login.md.

Once a user signs in, window.nostr is installed with the de facto NIP-07 shape (getPublicKey, signEvent, getRelays, nip44.encrypt/decrypt), so existing Nostr web apps work against it unmodified — routed to whichever signer backs the session (the worker for password/passkey accounts, the worker-held NIP-46 client for remote-signer sessions; NIP-07 sessions leave the slot to the extension that owns it). Private keys are held in the worker and are not handed to the main thread by the signing APIs — those return public keys, signed events, and ciphertext only.

Be precise about what that does and does not buy you. The worker protects keys from accidental exposure through the normal API surface; it is not a boundary against the host page itself. A page that embeds <bitlogin-auth> runs same-origin with it and can reach the element's worker client directly, so a malicious or XSS-compromised host can call exportIdentity and obtain the nsec, and can synthesize UI events (the widget rejects untrusted ones, and can still be visually overlaid by host CSS). This is inherent to an embedded same-origin component — the same reason docs/connection-vault.md §12.3 says wallet credentials are revealed, not brokered. Treat the host page as trusted; embed BitLogin only in sites you control, and prefer the recovery phrase (never the exported nsec) as the durable backup. A cross-origin or extension build is what would change this, and is not shipped. See packages/demo/public/docs.html for the full integration guide, and packages/demo/public/account.html for a working example (create → confirm recovery phrase → sign an event → rotate password → export identity).

Sessions persist across a page reload. After a successful sign-in, the everyday private key is cached in IndexedDB for that origin, so a later reload restores the session and fires bitlogin-login automatically — your existing listener doesn't need to distinguish "just typed" from "silently restored." This trades "nothing persists locally" for "the decrypted key sits in this browser's IndexedDB until logout," the same tradeoff a NIP-07 extension already makes. Calling logout() (or the element's dashboard "Log out" button) clears it.

The <bitlogin-auth> element also mirrors window.nostr directly on the element instance (getPublicKey, signEvent, nip44Encrypt, nip44Decrypt) — prefer this over window.nostr when your host page holds a reference to its own widget instance, since window.nostr is a single global slot that whichever provider signed in last currently owns.

Wallet connections (Connection Vault, reveal mode). An app can ask the user to share an NWC wallet connection with its origin:

const uri = await document
  .querySelector("bitlogin-auth")
  .requestNwcConnection({
    appName: "Satisfied",
    reason: "Pay for meal analysis",
  });
if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined

The widget owns the whole ceremony: sign-in first if needed, a one-tap approval when the account already has a connection bound to this origin (that's the new-phone moment the vault exists for), or a guided import — Bitcoin Connect's wallet chooser (loaded lazily; it never downloads unless the flow opens) or a pasted URI — stored as an encrypted vault record and restored on every device. Stated plainly: this is reveal mode. The requesting origin receives the full bearer credential and everything its wallet-side budget allows; an embedded same-origin widget cannot broker (docs/connection-vault.md §12.3), so it does not pretend to. Encourage users toward budget-capped connections — the wallet's own budget is the real security boundary. Users manage and revoke grants from the dashboard's "Wallet connections" screen. A bitlogin-connection-granted event fires on the element after each share. Hosts using this flow need one extra CSP line, style-src-attr 'unsafe-inline' (Bitcoin Connect's components set inline style attributes; a hash cannot cover those).

Integration gotcha: the element's disconnectedCallback terminates its crypto Web Worker (that's the correct cleanup when the element is genuinely being removed). If your host app re-renders its own DOM by resetting an ancestor's innerHTML — a common pattern in hand-rolled UI without a framework's reconciler — and the widget happens to be a descendant of that subtree, this silently kills the in-progress session: getPublicKey() already resolved before the render still looks fine, but every call after that hangs forever, because the worker is gone and nothing ever replies. Mount <bitlogin-auth> into a container that your own re-renders never replace (toggle visibility with the hidden attribute instead of removing it from the DOM), not into a template string that gets thrown away and rebuilt. See packages/demo/public/account.html for a working single-page example. The BitRoad project (a sibling Nostr commerce app that embeds BitLogin as a sign-in option) hit exactly this bug during integration and fixed it by giving the element a permanent, never-replaced mount point in its static shell HTML, only toggling hidden — see its src/nostr/bitloginAdapter.mjs and the #bitloginMount container in its index.html for a worked example of the pattern.

Moving the element is not a safe workaround, either. A same-document move — someContainer.appendChild(mount), slot.replaceWith(mount), or similar, to relocate a statically-declared mount point into a dynamically-rendered slot — still fires disconnectedCallback (removal) and then connectedCallback (insertion) on <bitlogin-auth>, exactly like a genuine remove-and-recreate. The worker still gets terminated, and because connectedCallback doesn't construct a new WorkerClient (that only happens once, in the constructor), the element is left reusing a now-dead worker: every subsequent message — configure, login, everything — silently hangs forever with no error, since postMessage on a terminated Worker is a no-op. BitUnlock hit exactly this while trying to work around the innerHTML gotcha above: it kept #bitloginMount static but used replaceWith() to relocate it into a per-step placeholder, believing a move (vs. removal) would spare the worker. It doesn't. The fix is the same as above — never move it either; keep it in one fixed DOM position for its entire lifetime and let CSS/layout put other content around it.

Required CSP allowance: password hashing (Argon2id, via hash-wasm) compiles a WebAssembly module inside the crypto worker. A script-src that doesn't include 'wasm-unsafe-eval' (or the broader 'unsafe-eval') blocks WebAssembly.compile() outright — every action that touches a password (register, login, recover, changePassword) fails with a CSP error. If your site sets its own script-src, add 'wasm-unsafe-eval' to it:

script-src 'self' 'wasm-unsafe-eval';

This is easy to miss because the failure is scoped to the crypto worker — the rest of the page (and even a plain main-thread WebAssembly.compile() check) can work fine while every BitLogin auth action still fails.

Another integration gotcha, now fixed: some NIP-07 browser extensions install window.nostr as a non-configurable, non-writable property specifically to stop another script from overwriting it. claimSigner() (called automatically after every sign-in) used to do a plain window.nostr = ... assignment with no guard, so on a page where such an extension is active, that line threw TypeError: Cannot assign to read only property 'nostr' of object '#<Window>' — and since it ran synchronously before the widget dispatched bitlogin-login or moved to the dashboard screen, this froze account creation/import at the final "verify your recovery phrase" step, with that raw TypeError as the only (confusing) feedback. claimSigner() now catches this, returns false instead of throwing, and every caller proceeds to complete sign-in regardless — a host page using this element's own methods (as documented above) was never actually affected by the failed window.nostr takeover anyway. The widget's own dashboard now also shows a plain-language warning in this case ("Another Nostr signer (browser extension) is active in this browser and couldn't be replaced...") using the same warning banner already shown for rollback and relay-disagreement conditions.

Theming

<bitlogin-auth> renders in a shadow root, but its colors, radius, font, and max-width are all read from CSS custom properties on :host — and custom properties inherit through the shadow boundary, so a host page can override every one of them from ordinary light-DOM CSS (a rule targeting the element, or an inline style), without touching the widget's internals:

bitlogin-auth {
  --bl-accent: #3d9bff; /* primary button / link / focus ring color */
  --bl-accent-hover: #2f86e0; /* primary button hover */
  --bl-accent-fg: #04101f; /* text color on top of --bl-accent */
  --bl-bg: #12151f; /* card background */
  --bl-fg: #eaeef6; /* body text */
  --bl-muted: #98a2b6; /* secondary text */
  --bl-border: rgba(255, 255, 255, 0.1);
  --bl-input-bg: #1a1e2b; /* input/credential-box background */
  --bl-danger: #ff6b6b;
  --bl-danger-bg: rgba(255, 107, 107, 0.16);
  --bl-warn: #ff6b6b;
  --bl-warn-bg: rgba(255, 107, 107, 0.16);
  --bl-radius: 14px;
  --bl-font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  --bl-max-width: none; /* default 380px keeps it a fixed-width card;
                                  override to fill a wider container */
}

All of these have defaults (a light theme, with a prefers-color-scheme: dark media-query fallback and a data-theme="dark"|"light" attribute override for host pages with their own theme toggle), so overriding none of them is a valid choice — the widget just uses its own look. See BitRoad's bitlogin-auth { ... } rule in src/styles.css for a real integration that maps every one of these to its own design tokens.

Deployment

The demo site deploys to Vercel: vercel.json at the repo root sets the build command (core → widget → demo) and output directory, so importing this repo at vercel.com/new needs no manual configuration. GitHub Pages was evaluated but dropped — Pages requires a paid plan for private repositories, whereas Vercel's free tier deploys a private repo without that restriction.

The widget and its dedicated worker must be served from the application's own origin. Build the reviewed source, then vendor the complete output of packages/widget/dist/ into your deployment:

<script type="module" src="/vendor/bitlogin/bitlogin.js"></script>
<bitlogin-auth></bitlogin-auth>

Keep bitlogin.js, cryptoWorker.js, and every bitlogin-shared-*.js chunk together. Do not point production applications directly at the demo domain: cross-origin workers are rejected by browsers, and remotely mutable account code would add an unnecessary supply-chain trust dependency.

bitlogin.network and www.bitlogin.network are attached to the same Vercel project as production domains (bitlogin.vercel.app keeps working as the underlying *.vercel.app alias regardless). DNS for a domain bought outside Vercel has to be pointed at it separately — see "Custom domain DNS" below if you're standing this project up on a new domain yourself.

The Vercel deployment is the reference demo and account manager, not a widget CDN. Published release checksums and an SBOM should be verified before vendoring artifacts into another application.

Custom domain DNS

Vercel doesn't manage DNS for a domain unless its nameservers are delegated to Vercel; with third-party DNS (this project's domains sit on Cloudflare nameservers, registrar-agnostic), add these records at whichever provider actually serves the zone:

A       bitlogin.network         76.76.21.21
A       www.bitlogin.network     76.76.21.21

Run vercel domains inspect bitlogin.network (from a machine with the Vercel CLI linked to this project) to confirm the exact records currently expected — Vercel occasionally changes its recommended anycast IP, and that command always reflects the live requirement rather than whatever's written here. If the DNS host proxies through something like Cloudflare, set the record to DNS-only (grey cloud) rather than proxied — a proxying layer in front of Vercel's own edge can interfere with certificate issuance and routing.

Updating your integration

There's no npm package or auto-updating CDN release yet (see "Deployment" above) — each site vendors its own copy of the built widget, so updates to this repo do not automatically reach a site that already integrated BitLogin. This is deliberate, not an oversight: a shared script every site loads live would mean a single bad or compromised deploy here breaks (or worse, compromises) every consuming site at once, with no per-site review window. Since this widget handles private key crypto, that trade is worth the manual step.

Check CHANGELOG.md periodically (or whenever a site reports an auth issue) for entries relevant to your integration — each one says whether it applies to sites using the default built-in relay list vs. a custom one, and similar integration-relevant conditions. To pull in a fix or update:

# from your site's repo, with a sibling ../bitlogin checkout pulled to the
# commit you want (git log / CHANGELOG.md to pick one)
node scripts/build-bitlogin-widget.mjs
# or, if bitlogin isn't checked out as a sibling directory:
BITLOGIN_ENTRY=/path/to/bitlogin node scripts/build-bitlogin-widget.mjs

This rebuilds @bitlogin/core/@bitlogin/widget if needed and copies the built bitlogin.js/cryptoWorker.js/shared-chunk output verbatim into your site's vendor/bitlogin/ (or src/vendor/bitlogin/), alongside a vendor/bitlogin.integrity.json recording the exact file hashes vendored. Diff that file before and after to confirm what actually changed, then commit, test, and deploy through your site's normal process — the whole point of vendoring is that this is a reviewed step, not an automatic one.

What's implemented

This build covers the protocol's Phase 0 (cryptographic core + test vectors) and the load-bearing parts of Phase 1 (account MVP), verified two ways:

  • Protocol-level tests (npm test, 120 tests): Argon2id/ScalarExpand/JCS/ padding/timestamp/NIP-44 unit tests with the spec's exact byte layouts (§11), and full end-to-end scenarios — create → clean-device login, phrase recovery → new credentials → clean-device login, password change with mandatory tombstone + NIP-09 deletion, relay-loss → replica repair (including keyless recovery-event rebroadcast), generation-rollback detection failing closed by default rather than only warning (§16.2 step 6), initial profile/relay-list publication never overwriting an existing kind 0/10002/10050 event when the everyday identity is an imported nsec (§15.8, §28.1), and registration/password-rotation refusing to overwrite another account already registered at the same locator address (§15.6, §18.1) — run against an in-memory mock relay (packages/core/src/test-support/mockRelay.ts), since this sandbox has no route to public Nostr relays. NIP-44 has additionally been cross-tested directly against the nostr-tools reference implementation (conversation keys, padding at every bucket boundary, and round trips in both directions) during the BitRoad integration — see "Changes from v0.3" in docs/spec.md.
  • Real-browser verification: the same flows were driven end-to-end in actual Chromium against the built widget, confirming the crypto worker, IndexedDB storage, and window.nostr provider work together outside the test harness.

Implemented: registration (§15), any-device password login with rollback and relay-disagreement detection (§16), phrase recovery with recovery-capsule refresh (§17), known-password change with mandatory tombstone + NIP-09 deletion (§18), replica repair (§24.4), recovery export (§19.5), NIP-19 nsec/npub encoding, importing an existing Nostr nsec as the everyday identity (§28.1, §SF10 — with a preview-before-commit UI step), a NIP-44 v2 implementation for the window.nostr.nip44 surface, bootstrap/discovery relay list plumbing (§19), and the full crypto stack from §11 (Argon2id, HKDF, ScalarExpand, AES-256-GCM, JCS, fixed padding buckets).

Deferred / not in this pass:

  • Phase 2 messaging (NIP-17 inbox/compose) — the NIP-44 primitive is built and tested, but no conversation UI is wired up.
  • Phase 3 application redirect-auth flow (§26.3). (NIP-07 and NIP-46 sign-in, anticipated by §26.4, shipped 2026-08-04 — see docs/login-methods.md; NIP-46 credential persistence stays gated on the external vault review.)
  • NIP-49 ncryptsec export (§28.2) — deliberately left out rather than shipped unverified against official test vectors; plain nsec/npub (NIP-19) export is implemented instead.
  • The maintainer-signed bootstrap relay-list channel (§19.1) has the verify/merge logic implemented but is deliberately disabled (null) until a real controlled maintainer key and rotation/revocation process are reviewed and announced out-of-band.
  • Native clients, hardware-backed recovery, independent second implementation, and formal security audit (Phase 4).

Development notes

  • Crypto primitives use audited libraries: @noble/curves (secp256k1 Schnorr), @noble/hashes (SHA-256, HKDF, HMAC), @noble/ciphers (ChaCha20 for NIP-44), @scure/bip39 (BIP-39), native WebCrypto (AES-256-GCM), and hash-wasm (Argon2id).
  • The Argon2id profile (bitlogin-argon2id-v1) uses parallelism 1 rather than RFC 9106's recommended 4, for reasons documented in packages/core/src/crypto/argon2id.ts (§11.2 of the spec).
  • Passphrases are generated from the EFF long Diceware wordlist (diceware-wordlist-en-eff), matching the spec's ~12.9-bits/word, six-word/~77-bit default (§9.2).

Contributing

MIT licensed — see LICENSE. See CONTRIBUTING.md for the dev workflow, mock-relay testing (no route to public relays in most sandboxes/CI), and the spec-first process for anything touching wire format or crypto. Found a security issue? See SECURITY.md rather than opening a public issue.

Languages
TypeScript 89.6%
HTML 7.8%
CSS 1.7%
JavaScript 0.9%