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.
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.
<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.
<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.
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');
});
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.
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);
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.
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.
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.
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.
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.
<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).
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.
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.
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.
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.
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.
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.