npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

bssh-agent

v1.0.0

Published

browser-ssh-agent: SSH agent forwarding over WebSocket — keys live in the browser, signing is relayed to a Node.js SSH client

Downloads

258

Readme

browser-ssh-agent

npm version CI Known Vulnerabilities Coverage Status Maintainability Rating

日本語

SSH agent forwarding over WebSocket: the private key lives in a browser tab, signing is relayed to a Node.js SSH client. A reimplementation of ssh -A where the "agent" is a paired browser session instead of a local socket.

How it works

Pairing (step 2 below) happens once; using ssh/git (step 3) then works for as long as the browser tab stays connected, with no further setup.

sequenceDiagram
    actor User
    participant Browser as Browser tab<br/>(holds the key)
    participant Server as Your app<br/>(bssh-agent)
    participant SSH as ssh / git<br/>(local CLI)
    participant Remote as Remote SSH host

    rect rgb(245,245,245)
    Note over Server: 1. Start the server
    Server->>Server: attachTo() + startUnixSocket()
    Server->>Server: createPairingLink() -> pairing URL
    end

    rect rgb(245,245,245)
    Note over User,Server: 2. Pair the browser (once)
    Server-->>User: Show "Pair your key" link on the page
    User->>Browser: Click the link
    User->>Browser: Select key file + enter passphrase
    Browser->>Browser: Decrypt key locally - never leaves the tab
    Browser->>Server: WebSocket connect + hello(token)
    Server-->>Browser: hello-ack (paired)
    end

    rect rgb(245,245,245)
    Note over User,Remote: 3. Use ssh / git - any time after pairing
    Server->>SSH: spawn ssh/git via agentServer.env()
    SSH->>Server: via SSH_AUTH_SOCK: list identities
    Server->>Browser: relay request
    Browser-->>Server: public key
    Server-->>SSH: public key
    SSH->>Remote: offer publickey auth
    Remote-->>SSH: sign this challenge
    SSH->>Server: sign request
    Server->>Browser: relay request
    Browser-->>User: confirm this sign? (optional)
    Browser-->>Server: signature
    Server-->>SSH: signature
    SSH->>Remote: signed challenge
    Remote-->>SSH: authenticated
    SSH-->>Server: command output
    end

Status

The core of the library relays signing requests from a paired browser session to a real SSH_AUTH_SOCK Unix domain socket (agentServer.startUnixSocket()). Any ssh/git/rsync/scp process that picks up that socket authenticates using a key that only ever exists in the browser tab — never on the server, never on disk.

There are two ways to run the server side, and a widget that works with either:

  • bssh-agent CLI — a standalone, ssh-agent-style daemon: no host app required, SSH_AUTH_SOCK lands directly in your shell. See Standalone: the bssh-agent CLI.
  • AgentServer, embedded in your own app — attach to an HTTP(S) server you're already running, and spread agentServer.env() into child_process.spawn() calls your app makes. See Embedded: attach to your own HTTP server.
  • <bssh-agent-pairing> widget (bssh-agent/widget) — a drop-in Web Component wrapping the browser-side primitives below, so a host page needs only a <script> tag and a custom element instead of hand-writing a key form and confirm-sign dialog. Works with either mode above. See Browser, using the drop-in widget.

v1 supports Ed25519 keys only. RSA/ECDSA can be added by implementing the Signer interface (src/browser/signers/) — no protocol change required.

There's also a lower-level way to integrate directly with ssh2's own Client API instead of spawning external CLI tools — see Advanced.

Installation

npm install bssh-agent

This package is ESM-only (import, not require()). It publishes four subpath exports:

  • bssh-agent/server — the Node.js server-side API (AgentServer, ...).
  • bssh-agent/browser — browser-side primitives (loadKeyFromFile, connectAgent, ...).
  • bssh-agent/widget — the drop-in <bssh-agent-pairing> Web Component, built on top of bssh-agent/browser.
  • bssh-agent/shared — protocol types shared by all three of the above.

It also installs a bssh-agent CLI binary — run it via npx bssh-agent, or just bssh-agent once the package is installed.

See the API reference for the exhaustive list of every export, option, and event across all four subpaths.

Usage

Standalone: the bssh-agent CLI

A ready-to-run daemon, ssh-agent-style — no host app or code changes required. Best when a human is typing ssh/git/rsync directly into their own shell, or when you just want to try the library out:

eval "$(bssh-agent)"

This starts a background daemon, prints a pairing URL to stderr (and tries to open it in your default browser, unless --no-browser or a remote/SSH session is detected), and evals SSH_AUTH_SOCK/SSH_AGENT_PID into your current shell. Once you've paired a key via the opened page (using the <bssh-agent-pairing> widget below), every subsequent ssh/git/rsync in that shell picks up SSH_AUTH_SOCK for free — no code changes in any host app required. If a command runs before you finish pairing, it just fails auth cleanly and can be retried, the same way it would against a locked GUI keychain agent.

bssh-agent -k    # stop the daemon and unset the env vars: eval "$(bssh-agent -k)"

See the API reference for the full flag listing (-D/--foreground, --name, --force, --port, --host, --runtime-dir, ...).

Unlike real ssh-agent, bssh-agent -k must be able to find a running daemon from a different shell session than the one that started it, so it keeps a small state file (pid, socket path, port) under its runtime directory rather than relying solely on SSH_AGENT_PID.

Embedded: attach to your own HTTP server

Wire AgentServer into an app you're already building: attach to its existing HTTP(S) server and start the Unix socket transport.

import { createServer } from 'node:http';
import { spawn } from 'node:child_process';
import { AgentServer } from 'bssh-agent/server';

const httpServer = createServer(/* your app's existing request handler */);
const agentServer = new AgentServer();

agentServer.attachTo(httpServer, '/ws');
await agentServer.startUnixSocket();

// Render this as a link/button on a page the user is already viewing in
// their own browser (e.g. `<a href={url}>Pair your SSH key</a>`) — no need
// to relay it through any other channel. Never log it persistently — the
// pairing token lives in the fragment on purpose.
const { url } = agentServer.createPairingLink('https://your-app.example/pair');

agentServer.on('session-paired', () => {
  spawn('git', ['clone', '[email protected]:you/repo.git'], {
    env: { ...process.env, ...agentServer.env() },
    stdio: 'inherit',
  });
});

// Your app's own server startup — not part of this library. attachTo()
// only registers an 'upgrade' listener; it doesn't start listening itself.
httpServer.listen(8787);

The /ws path above matches <bssh-agent-pairing>'s zero-config default — see the widget section below. agentServer.listen(port) is also available as a standalone alternative to attachTo(); see the API reference.

Browser

Served from the page createPairingLink's baseUrl points at:

import { loadKeyFromFile, connectAgent } from 'bssh-agent/browser';

const token = new URLSearchParams(location.hash.slice(1)).get('token')!;
const key = await loadKeyFromFile(fileInput.files[0], passphraseInput.value);

const connection = connectAgent({
  wsUrl: 'wss://your-app.example:8787/ws',
  token,
  key,
  confirmSign: async ({ comment, fingerprint }) =>
    confirm(`Sign with ${comment} (${fingerprint})?`),
});

Browser, using the drop-in widget

<bssh-agent-pairing> wraps loadKeyFromText + connectAgent above into a single custom element with its own key-loading form and confirm-sign UI — add a <script> tag and the tag itself, no hand-written form or dialog required:

<script type="module" src="https://unpkg.com/[email protected]/dist/widget/index.js"></script>
<bssh-agent-pairing></bssh-agent-pairing>

Pin the version (as above) rather than floating on @latest — a breaking change in a later release shouldn't silently change what a copy-pasted <script> tag loads. Self-hosting dist/widget/index.js (e.g. from your own app's static assets after npm install) works the same way and avoids a runtime dependency on a third-party CDN.

By default it reads the pairing token from location.hash (matching createPairingLink()'s output) and derives its WebSocket URL from ${location.protocol}://${location.host}/ws — matching the attachTo(httpServer, '/ws') example above. Pass the ws-url attribute, or set the token/wsUrl properties from JS, if your host page needs to supply them explicitly (e.g. a different path, or embedded in an iframe).

It emits status-change, paired, error, sign-request, and key-forgotten CustomEvents so a host page can observe activity without touching bssh-agent/browser directly:

document.querySelector('bssh-agent-pairing').addEventListener('paired', () => {
  console.log('key paired and ready');
});

It zeroizes the decrypted key automatically on disconnect (and via its built-in "Forget key" button) — see Security notes — so the passphrase is always required again after a drop. What it does not require again is the file: the widget keeps the still-encrypted key file text cached in memory for the life of the page, so reconnecting after a dropped WebSocket (network blip, laptop sleep, tab backgrounded — anything short of actually closing the tab or reloading the page) shows a passphrase-only form instead of the full file picker. A "Use a different file" button is always available alongside it to discard the cache and go back to picking a file, and the explicit "Forget key" button always clears both the decrypted key and the cached file — there's no way to reconnect without the passphrase, only ways to avoid re-selecting the file. Caching the encrypted text costs nothing security-wise: it's exactly what the passphrase already protects, and the passphrase itself is never cached. Reconnecting still needs a fresh pairing token from your host app (tokens are single-use, and there's no session resumption) — only the key-loading step is skipped.

By default every sign request is approved automatically, with no prompt — this matches real ssh-agent's own default (ssh -A doesn't ask per-signature either). Set require-confirm="true" to show a built-in approve/deny prompt before each signature instead (the same trade-off real ssh-agent's optional -c/confirm mode offers — see Security notes), or set the confirmSign property to supply your own UI instead of the built-in one.

See the API reference for the full attribute/property/event listing.

Try it in Docker

A self-contained way to try the standalone CLI without installing Node locally:

docker build -t bssh-agent-demo .
docker run --rm --name bssh-agent-demo -p 8787:8787 bssh-agent-demo
docker logs bssh-agent-demo

The last command prints a Pairing URL: http://127.0.0.1:8787/#token=... line — open it in your own desktop browser, not inside the container. Its file picker reads a private key straight off your own machine's disk; the container never sees it, only the public key and signatures that cross the WebSocket afterward (see How it works). A throwaway keypair works fine for a first try. Once paired, signing happens without a prompt (the default — see Security notes for the trade-off, and --require-confirm if you'd rather approve each one).

Once paired, run a real ssh command through it from inside the container:

docker exec -it bssh-agent-demo sh -c \
  '. /run/bssh-agent/env.sh && ssh -o StrictHostKeyChecking=accept-new -T [email protected]'

Substitute your own remote host for [email protected] — it's used here only because it needs no extra server setup. The container binds its pairing page to 0.0.0.0 via --host so Docker's own port-publishing can reach it; outside a container the CLI's default stays loopback-only — see Security notes.

Advanced

Using agentServer.agent() directly with ssh2.Client

For apps that make SSH connections themselves through ssh2's own Client API (exec/sftp/forwardOut, agent-forwarded hops to further remote hosts) instead of spawning external ssh/git/rsync CLI processes, agentServer.agent() returns a ssh2-compatible BaseAgent you can pass directly:

agent: agentServer.agent(),
agentForward: true, // forwards further if the target host hops onward

This integration path is real and tested (agentServer.agent() has been usable since the project's earliest version), but isn't written up here in full yet — see src/server/transports/inProcessAgent.ts and the API reference in the meantime.

Delivering the pairing link across devices (QR code, printed link, ...)

The Embedded usage example assumes the simplest and safest case: the process minting the pairing link is the web app the user is already viewing in their own browser, so createPairingLink()'s URL can just be rendered as a link/button on that page — no separate delivery channel needed at all.

That assumption breaks down when whatever calls createPairingLink() has no browser of its own to render a link in — most notably the bssh-agent CLI (see Standalone), which may be running on a remote/headless machine you've SSH'd into. In that case the URL has to reach a different device's browser somehow: printing it for the user to copy-paste, or rendering it as a QR code for a phone to scan, are the two common approaches.

Both introduce a risk the <a href> case doesn't have: the URL — with its live, single-use token — now exists as something that can be captured and retained: a QR code image saved to a file, a printed page filed away, a terminal session that gets logged. That's the same risk class as never logging the pairing link persistently — a saved QR code image is a persistent log of the token. The token's short default TTL (5 minutes) bounds how long such exposure actually matters, but don't rely on that alone — treat any QR code or printed copy as something to discard once the pairing attempt is done, the same way you'd treat a password written on a sticky note.

Unattended access (no human present)

bssh-agent requires a human to keep a browser tab open for every signing operation (see How it works) — it isn't a fit for automation that must authenticate with nobody present. Two established alternatives apply instead, depending on what you control:

  • SSH certificates (TrustedUserCAKeys) — requires control over the remote host's sshd configuration, i.e. administering that host, not just holding a user account on it.
  • A dedicated keypair for the app server — works with an ordinary user account on the remote host, no root required. See the dedicated-key bootstrap guide for the exact commands and authorized_keys restrictions to use.

Known issues

Upstream ssh2 bug worked around: ssh2's AgentProtocol (server mode) mishandles the SSH_AGENTC_EXTENSION probe modern OpenSSH clients (8.9+) send before listing identities — it replies correctly but fails to skip the message's payload, desyncing the wire framing for everything after and silently wedging the whole agent connection. UnixSocketAgent filters and answers these probes itself before handing other messages to AgentProtocol; see the comment above pipeFilteringUnsupportedRequests in src/server/transports/unixSocketAgent.ts for details. Confirmed present through [email protected] (latest as of writing).

Security notes

  • Key material lives in the browser tab's JS heap for the session. This is the fundamental trade-off of the design (avoiding the server holding the key) and is not fully eliminable — mitigate with a minimal, dependency-light pairing page, a strict CSP (script-src 'self', no inline/eval), a dedicated tab rather than an iframe, and call KeyHandle.zeroize() on disconnect/idle.
  • Use wss:// off-loopback. Plain ws:// is only acceptable to 127.0.0.1.
  • By default, every sign request is approved automatically — this matches real ssh-agent's own default (ssh -A doesn't prompt per-signature either). The trade-off it shares with real agent forwarding: if the server relaying requests is ever compromised, an attacker can authenticate as you with the paired key for as long as the browser tab stays connected. (The agent protocol never reveals which remote host a challenge is for, only the key fingerprint, so there's no way to spot this from the request alone either way.) Set require-confirm="true" on the widget — or --require-confirm on the CLI — to show an approve/deny prompt for every signature instead, the same mitigation real ssh-agent's optional -c/confirm mode offers; or supply confirmSign directly for your own UI.
  • Pairing tokens are single-use and go in the URL fragment, never a query parameter (proxies commonly log those) and never a persistent log file.
  • The Unix socket file is a local privilege boundary: anything on the machine that can connect to it gets full "sign arbitrary challenges with the loaded key" power, equivalent in trust to real agent forwarding. It's created at a per-run unguessable path with 0600 permissions. No Windows named-pipe support — Unix domain socket only.
  • The CLI's self-served pairing page binds 127.0.0.1 only by default, never 0.0.0.0. Using it over SSH into a remote box requires you to ssh -L the pairing port yourself — the CLI skips auto-opening a browser when SSH_CONNECTION/SSH_TTY suggest a remote/headless session. --host overrides the bind address (e.g. --host 0.0.0.0 so Docker's own port-publishing can reach it — see Try it in Docker); only widen it within a network boundary you already trust, since it removes the loopback protection.
  • The widget caches the encrypted key file's text in memory across a disconnect, so reconnecting only asks for the passphrase, not the file — see Browser, using the drop-in widget. This is a deliberate, low-risk convenience: the cached text is exactly what the passphrase already protects, so caching it doesn't expose anything the passphrase-check doesn't already guard, and it's discarded entirely by "Forget key," "Use a different file," or a page reload. It is not the same as caching the decrypted key or the passphrase — those are never retained past a disconnect. If your threat model requires that even the encrypted file be forgotten immediately on disconnect, don't rely on the widget's default behavior here; this is the "least security-risk" convenience option, not a no-op.

Reference

See docs/REFERENCE.md for the exhaustive API reference — every export across all four subpaths, plus the full CLI flag listing — as opposed to this README's narrative getting-started coverage.

Development

npm install
npm run typecheck
npm test
npm run build
npm run test:cli   # builds, then exercises the real bssh-agent binary end-to-end