Integration guide

Add BitLogin to your site

No npm install required. Two files, one custom element — and every user who already has a BitLogin account elsewhere can sign in with the login they already know.

  1. Self-host the widget on your site's origin

    Build @bitlogin/widget from the reviewed source and copy bitlogin.js, cryptoWorker.js, and every bitlogin-shared-*.js chunk into a folder served by the same origin as your app, e.g. /vendor/bitlogin/. Keep every file together because the entrypoint resolves its worker and chunks by relative URL. Do not load the demo deployment directly from another origin.

  2. Include the script and the element

    <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>

    That's it — the element renders its own sign-in/create/recover UI inside a shadow root, so it won't collide with your site's CSS.

    Mount it somewhere your own re-renders won't touch. Removing <bitlogin-auth> from the DOM (including indirectly, by resetting an ancestor's innerHTML — a common pattern in hand-rolled UI without a framework's reconciler) tears down its crypto Web Worker. If that happens mid-session, every signing call made afterward just hangs forever with no error, because the worker is gone and nothing ever replies — a call already in flight when it happens can even look like it succeeded. If your app re-renders the container this element lives in, give it a container that's never replaced wholesale and toggle the hidden attribute instead of removing the element. See the account manager demo's source for a single-page example, or BitRoad's src/nostr/bitloginAdapter.mjs / #bitloginMount (a sibling Nostr commerce app that embeds BitLogin) for a worked example of keeping the element alive across a host app's own re-renders.
  3. React to sign-in and sign-out

    const bl = document.querySelector('bitlogin-auth');
    
    bl.addEventListener('bitlogin-login', (e) => {
      console.log('signed in as', e.detail.publicKey);
      console.log('via', e.detail.method); // 'bitlogin' | 'nip07' | 'nip46'
      console.log('supports', e.detail.capabilities); // { nip44, nip04, getRelays }
    });
    
    bl.addEventListener('bitlogin-logout', () => {
      console.log('signed out');
    });
    One login surface, several methods. If the visitor's browser has a NIP-07 signer extension (Alby, nos2x, …), the widget's welcome screen offers "Use your Nostr extension instead" below the primary username/password sign-in, and every visitor gets "Use a remote signer (bunker)" — a NIP-46 connect screen with a scannable QR for phone signers (Amber, nsec.app) and a bunker:// paste box. You embed one element and all three kinds of users are covered. detail.method tells you which way the user came in, and detail.capabilities what the active signer supports (some extensions lack nip44). Extension sessions are signer-only: signing and encryption work through the element exactly the same, but BitLogin account features — wallet connections, password rotation, identity export, cross-device persistence — belong to BitLogin accounts, and requestNwcConnection() resolves null. A page reload ends an extension session; the extension is simply asked again next visit.
  4. Sign events from anywhere on the page

    Once a user is signed in, BitLogin installs a window.nostr provider shaped like the de facto NIP-07 interface used by browser extensions (Alby, nos2x, …). Any existing Nostr web app code that already calls window.nostr works unmodified.

    const pubkey = await window.nostr.getPublicKey();
    
    const signed = await window.nostr.signEvent({
      kind: 1,
      content: 'hello from my app',
      tags: [],
      created_at: Math.floor(Date.now() / 1000),
    });
    
    // NIP-44 encrypted DMs
    const cipher = await window.nostr.nip44.encrypt(peerPubkeyHex, 'hi there');
    const plain  = await window.nostr.nip44.decrypt(peerPubkeyHex, cipher);
    Private keys live in a dedicated Web Worker. The signing APIs return only public keys, signed events, and ciphertext — the main thread never receives a private key through them. Be precise about the limit, though: an embedded widget runs same-origin with the page hosting it, so a malicious or XSS-compromised host can reach the worker directly (including exportIdentity, which backs the widget's own "reveal private key" button) and can visually overlay the consent UI. Treat the host page as trusted and embed BitLogin only in sites you control.
  5. Or talk to the element directly — no window.nostr contention

    <bitlogin-auth> mirrors the same four calls as instance methods, scoped to that specific element rather than the single shared window.nostr slot:

    const bl = document.querySelector('bitlogin-auth');
    
    const pubkey = await bl.getPublicKey();
    const signed = await bl.signEvent({ kind: 1, content: 'hello', tags: [] });
    const cipher = await bl.nip44Encrypt(peerPubkeyHex, 'hi there');
    const plain  = await bl.nip44Decrypt(peerPubkeyHex, cipher);

    Prefer this if your app already holds a reference to the element — e.g. you built the sign-in UI yourself instead of relying on window.nostr-reading app code. It keeps working even if something else currently owns window.nostr (another extension, a different widget instance), and it's what a host app should use if it wants a specific, known signer rather than "whichever provider is active this moment." BitRoad's src/nostr/bitloginAdapter.mjs is a real example: its signer adapter calls these four methods directly and never touches window.nostr at all.

  6. Ask for a wallet connection (NWC, Connection Vault)

    If your app takes Lightning payments, stop asking users to paste an NWC string on every device. One call asks BitLogin for a wallet connection bound to your origin:

    const uri = await bl.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 nobody is signed in, a one-tap approval when the account already has a connection bound to your origin (the connection follows the user to every new device), or a guided first-time import — Bitcoin Connect's wallet chooser, or a pasted URI — stored as an encrypted record on the user's account. A bitlogin-connection-granted event fires on the element after each share, and users manage or revoke grants from the widget dashboard's "Wallet connections" screen.

    The inverse direction exists too: if your app obtained a connection by its own means (its own wallet chooser, its own paste box), offer the user a portable copy —

    const outcome = await bl.offerNwcConnection(uri, { appName: 'Satisfied' });
    // 'saved' | 'declined' | 'already-saved' | 'unavailable'

    The widget shows its own consent screen ("Save this wallet to your BitLogin?") and never saves silently — the write goes to the user's account, so it happens in BitLogin's chrome or not at all. Duplicates (same wallet + secret) resolve already-saved with no UI, so it's safe to fire after every successful connect. unavailable means nobody is signed in or the account has no vault; treat it as a no-op.

    This is reveal mode, and the consent screen says so. Your origin receives the full bearer credential and everything its wallet-side budget allows — an embedded same-origin widget is not a security boundary against its host page, so BitLogin doesn't pretend to broker. Encourage budget-capped connections: the budget the user's wallet enforces is the real security boundary. Two practical requirements: the wallet-connect flow needs style-src-attr 'unsafe-inline' in your CSP (Bitcoin Connect's components set inline style attributes; hashes can't cover those), and accounts created before the Connection Vault existed must enable it once from the account manager (recovery phrase required) before connections can be saved — the flow still hands your app the URI either way, just labeled as unsaved.
  7. One login surface — checking who's signed in, and how

    The recommended integration is to make <bitlogin-auth> your site's only login UI: it already offers username/password, a NIP-07 extension, and a NIP-46 remote signer as sub-options behind one surface, and routes every signing call to whichever the user picked. You don't need your own method picker, and your app code doesn't branch by method.

    That includes "Continue with a passkey" — zero setup on your side, no OAuth client to register, no server, no CSP changes. A PRF-capable passkey (synced for most users by their Google or Apple account) deterministically derives the credential of a real, portable Nostr account; nothing is stored anywhere but the user's own authenticator. Users can bring an existing Nostr key at signup, and every passkey account completes the 12-word recovery ceremony before its first session — it is mandatory, so a lost passkey, a lost Google/Apple account, or a changed site domain can never trap anyone. Rotating the password later "graduates" the account to fully self-held. See docs/passkey-login.md in the repo for the derivation contract and loss/recovery model.

    Two different questions have two different APIs, and conflating them is the classic integration bug:

    "Is someone signed in through BitLogin?" — use window.bitlogin.activeMethod() (or the richer activeSession()), or listen for bitlogin-login. This answers correctly for every method:

    const method = window.bitlogin?.activeMethod();
    // 'bitlogin' | 'nip07' | 'nip46' | null
    
    const session = window.bitlogin?.activeSession();
    // { method, publicKey, npub } | null — public data only

    "Does BitLogin currently back window.nostr?" — use window.bitlogin.isActiveSigner(). Note the deliberate asymmetry: during a NIP-07 session the user's extension rightly owns window.nostr (it is the signer), so isActiveSigner() is false while activeMethod() is 'nip07'. Don't use isActiveSigner() as a session check.

    window.bitlogin is installed as soon as the script loads, before anyone signs in, so both are safe to feature-detect immediately. window.nostr itself works in every case — backed by BitLogin's provider for password and remote-signer sessions, and by the extension itself for extension sessions — so existing Nostr app code calling window.nostr needs no changes either way.

    If your site does keep its own signer picker alongside BitLogin: BitLogin never overwrites an extension's window.nostr on load (it only claims the empty slot, and on sign-in), and window.bitlogin.releaseSigner() / element.claimSigner() let you hand the slot back and forth without a reload, as described below.

    Don't give your <bitlogin-auth> element id="bitlogin". Browsers automatically expose any element with a matching id or name attribute as a same-named global — an element with id="bitlogin" becomes window.bitlogin, shadowing the API object described above. Use something like id="bitlogin-widget" instead (as this demo does).

    Letting a user switch away from BitLogin without reloading

    One exception first: when a user signs in through a NIP-07 extension from the widget's own welcome screen, BitLogin deliberately leaves window.nostr alone — the extension already owns the slot and is the session's actual backend, so there is nothing to claim or release. Everything below concerns BitLogin-account sessions, where the widget's own provider competes for the slot.

    By default, once <bitlogin-auth> mounts it claims window.nostr and keeps it — even after the user logs out of BitLogin, since they might just be about to log back in with the same widget. If your site offers a signer picker and the user switches to a different method, call window.bitlogin.releaseSigner() to hand the slot back (it's a no-op, safe to call speculatively, if BitLogin doesn't currently hold it):

    // User picked a different signing method in your own UI
    if (window.bitlogin?.releaseSigner()) {
      // window.nostr is now undefined; install the other method's provider
    }

    If they switch back to BitLogin later, call claimSigner() on the element instance to reclaim the slot (this also happens automatically every time a user completes sign-in, sign-up, or recovery through the widget itself, so you only need this for a picker UI that lives outside the widget):

    document.querySelector('bitlogin-auth').claimSigner();

    Both claimSigner()/releaseSigner() on the element and window.bitlogin.releaseSigner() also fire bitlogin-signer-claimed / bitlogin-signer-released events (on the element and on window, respectively) if you'd rather react to the change than check a return value.

    claimSigner() can fail without breaking anything. Some NIP-07 extensions install window.nostr as a non-configurable, non-writable property specifically to stop another script from overwriting it. When that happens, claimSigner() returns false instead of throwing (and bitlogin-signer-claimed's event.detail carries { windowNostrClaimed: false, error }), and every BitLogin sign-in flow completes normally regardless — the failure only means window.nostr still points at that other extension. If your app talks to the element directly (previous step) this never matters at all; only code that reads window.nostr itself is affected, and only for as long as the other extension holds the slot.
  8. Choose your relays

    BitLogin ships with a small built-in bootstrap relay list, but you should point vault-relays and discovery-relays at relays you trust for your users. Use at least three vault relays for redundancy — capsule publication and reads use a quorum, so no single relay is a single point of failure.

  9. Make it look like your site

    The widget renders in a shadow root, but every color, radius, font, and its max-width are all read from CSS custom properties on :host — and custom properties inherit through the shadow boundary, so your page's own CSS can override every one of them 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.10);
      --bl-input-bg: #1a1e2b;     /* input / credential-box background */
      --bl-danger: #ff6b6b;
      --bl-danger-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 */
    }

    Overriding none of these is a valid choice — the widget falls back to its own light theme, a prefers-color-scheme: dark media query, and a data-theme="dark"|"light" attribute override for pages with their own theme toggle. See BitRoad's bitlogin-auth { ... } rule in its src/styles.css for a real integration that maps every one of these onto its own design tokens.

  10. Showing it as one option among several

    If your sign-in screen lists BitLogin alongside other methods (an extension, a bunker, email) rather than embedding the full widget up front, use the brand mark instead — the same seal shown on this site and inside the widget's own header, as a plain SVG with a transparent background:

    <button>
      <img src="/assets/mark.svg" alt="" width="20" height="20" />
      Sign in with BitLogin
    </button>

    Grab mark.svg (icon only), wordmark.svg (the "BitLogin" logotype as vector paths — no font to load), or lockup.svg (both combined) from this site's own assets/ folder. All three are plain SVG with no external dependencies, and wordmark.svg's text is filled with currentColor so it follows your page's own text color.

  11. That's the whole integration

    No account database, no password-reset email, no server-side session. The account manager demo shows the full loop: create an account, sign a test event, rotate the password, and export a recovery file.

Password-manager save prompts. Because the widget's login form lives inside a shadow root and submits via JavaScript (never a real form POST), browsers' passive "offer to save this password" heuristics don't reliably fire — and the generated-password screen never puts the password into a real input at all, since it's only shown as text. The widget works around this by explicitly calling the Credential Management API (navigator.credentials.store()) after every successful registration, login, password rotation, and recovery. This is Chromium-only (Chrome, Edge, Brave, Opera) — Firefox and Safari never implemented PasswordCredential, so users on those browsers will need to save their generated password manually.