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

byok-vault

v0.2.2

Published

Browser-native BYOK vault with encrypted storage and token circuit breaker.

Readme

byok-vault

npm version license bundle size

Browser-native BYOK vault for serverless/local-first AI apps. Docs site: https://floaredor.github.io/byok-vault/

Why?

Building serverless AI apps still forces a bad tradeoff: run a backend only to hide API keys, or ask users to paste raw keys into a plaintext browser field.

What?

byok-vault is a tiny browser library that encrypts each user API key locally with a passphrase. Keys are only decrypted for the exact scope of an API call via withKey(...), and an optional token circuit breaker helps prevent unbounded calls without user awareness.

Security Reality Check (Read First)

  • This project has not been formally audited.
  • It protects against passive issues (plaintext keys in storage, accidental exposure, low-effort scraping).
  • It does not protect against active in-origin script injection (XSS). If malicious JS executes on your origin, it can still intercept decrypted keys in-flight.
  • sessionStorage caching is a UX optimization to reduce passphrase prompts, not a stronger security boundary.

If your threat model requires resistance to active injection attacks, use a server-side proxy.

What It Provides

  • AES-GCM encryption at rest in localStorage.
  • PBKDF2 key derivation (default 200,000 iterations) with per-user random salt.
  • Encrypted JSON vault config support (apiKey + metadata such as org/model preferences).
  • Scoped key access via withKey(async (key) => { ... }).
  • Optional passkey unlock flow (setConfigWithPasskey / unlockWithPasskey) for biometric UX.
  • Optional token circuit breaker with:
    • pre-flight soft check (requestedTokens)
    • post-call hard accounting (reportUsage(tokens))
    • dev warning when withKey finishes without reportUsage.
  • nuke() reset flow to clear encrypted key and session state.

Why Use This

Most BYOK apps choose between two bad defaults:

  • plaintext key entry in the browser (trust-killing UX), or
  • rolling custom client-side crypto where implementation mistakes are common.

byok-vault is useful when you want browser-native key handling with opinionated defaults:

  • encrypted-at-rest storage with per-key random salt and AES-GCM,
  • scoped key access (withKey) instead of wide key plumbing through app code,
  • built-in token budget circuit breaker (requestedTokens + reportUsage).

Use this if your threat model is client-side BYOK with passive exposure concerns.
Do not use this as an active-XSS defense; use a server-side proxy for that.

Why not use OpenRouter BYOK?

OpenRouter is a fantastic platform for LLM routing, and their Bring-Your-Own-Key feature is great if you want managed infrastructure. However, it is fundamentally a server-side proxy.

Use byok-vault instead of OpenRouter BYOK if you are building a local-first application and need:

  1. Zero Onboarding Friction: OpenRouter BYOK requires your users to leave your app, create an OpenRouter account, paste their OpenAI/Anthropic key into OpenRouter's dashboard, generate a new proxy key, and paste that back into your app. With byok-vault, users paste their key directly into your app and start working immediately.
  2. Zero Middlemen: OpenRouter requires users to store their API keys on OpenRouter's servers, and all API calls are routed through their backend. byok-vault keeps the key strictly inside the user's browser. It never touches a server.
  3. Zero Proxy Fees: While OpenRouter offers a generous free tier for BYOK, high-volume apps eventually hit a 5% routing tax just to use their own keys. byok-vault talks directly to the LLM provider: no proxy, no tax.

Install

npm install byok-vault

Quick Start (Passphrase)

import { BYOKVault } from "byok-vault";

const vault = new BYOKVault();

await vault.setConfig(
  {
    apiKey: userApiKey,
    provider: "openai",
    organizationId: userOrgId
  },
  userPassphrase
);

await vault.withConfig(async (config) => {
  await fetch("https://api.example.com/llm", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${config.apiKey}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ prompt: "hello" })
  });
});

Quick Start (Passkey)

import { BYOKVault } from "byok-vault";

const vault = new BYOKVault();

await vault.setConfigWithPasskey(
  {
    apiKey: userApiKey,
    provider: "openai"
  },
  {
    rpName: "Your App Name",
    userName: currentUser.email
  }
);

vault.lock();
await vault.unlockWithPasskey();

Recommended UX Flow

  1. First-time setup (state === "none"): collect key + passphrase, call setConfig(...).
  2. Locked (state === "locked"): prompt unlock with unlock(...) or unlockWithPasskey().
  3. Unlocked (state === "unlocked"): run provider calls inside withConfig(...) or withKey(...).
  4. Optional controls: lock() for session lock and nuke() for full reset.
if (vault.getState() === "none") {
  await vault.setConfig({ apiKey: userApiKey }, passphrase);
}

if (vault.getState() === "locked") {
  await vault.unlock(passphrase, { session: "tab" });
}

await vault.withKey(async (key) => callProvider(key));

Error Handling

import { BYOKVaultError, getUserMessage } from "byok-vault";

try {
  await vault.unlock(userPassphrase);
} catch (error) {
  if (error instanceof BYOKVaultError) {
    // Optional: branch by error.code for custom UX.
    if (error.code === "WRONG_PASSPHRASE") {
      showToast("Wrong passphrase. Please try again.");
      return;
    }
  }
  showToast(getUserMessage(error));
}

Optional: Token Budget (Circuit Breaker)

const vault = new BYOKVault({
  maxTokens: 30_000,
  hardMinTokens: 5_000,
  hardMaxTokens: 100_000
});

// user-selected runtime override (for example from a settings UI)
vault.setMaxTokens(50_000);

await vault.withKey(
  async (key) => {
    const response = await fetch("https://api.example.com/llm", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify({ prompt: "hello" })
    }).then((r) => r.json());

    const used = response.usage?.total_tokens ?? 0;
    vault.reportUsage(used); // hard usage accounting
  },
  {
    requestedTokens: 1200 // optional soft pre-flight estimate
  }
);

Circuit Breaker Notes

  • requestedTokens check is a soft guardrail based on your estimate.
  • reportUsage(tokens) is the hard truth.
  • When limit is exceeded, the next request is blocked with a hard error.
  • In dev mode, vault warns if withKey returns successfully without reportUsage.
  • setMaxTokens(limit) lets you adjust the active limit at runtime.
  • hardMinTokens and hardMaxTokens enforce developer-defined bounds for runtime updates.

Provider Usage Parsing Snippets

OpenAI-style:

const tokens = response.usage?.total_tokens ?? 0;
vault.reportUsage(tokens);

Anthropic-style:

const input = response.usage?.input_tokens ?? 0;
const output = response.usage?.output_tokens ?? 0;
vault.reportUsage(input + output);

Plaintext Key Migration

const legacyStorageKey = "openai_api_key";
const plainKey = localStorage.getItem(legacyStorageKey);

if (plainKey) {
  await vault.importKey(plainKey, userPassphrase, {
    clearStorageKey: legacyStorageKey
  });
}

Migration checklist:

  1. Detect legacy plaintext key in storage.
  2. Prompt user for passphrase.
  3. Call importKey(plainKey, passphrase).
  4. Remove only legacy plaintext key storage.

If plaintext key is nested inside JSON config, extract only that field and keep the rest:

const configStorageKey = "model_settings";
const config = JSON.parse(localStorage.getItem(configStorageKey) ?? "{}");
if (typeof config.apiKey === "string" && config.apiKey.length > 0) {
  await vault.importKey(config.apiKey, userPassphrase);
  delete config.apiKey;
  localStorage.setItem(configStorageKey, JSON.stringify(config));
}

Stream-Friendly Scope Pattern

await vault.withKeyScope(
  async () => streamProviderResponse(),
  {
    passphrase: userPassphrase,
    session: "action"
  }
);

withKeyScope keeps unlock state for the Promise lifetime of the callback. It does not turn the callback into an async generator context, so you cannot yield from inside that callback. For true streaming UIs, start the stream inside the scope and forward chunks outside it (for example through events/queue/state updates).

React Helper Pattern

function useVaultState(vault: BYOKVault) {
  const [state, setState] = useState(vault.getState());

  const refresh = useCallback(() => setState(vault.getState()), [vault]);

  const unlock = useCallback(
    async (passphrase: string, session: "tab" | "action" = "tab") => {
      await vault.unlock(passphrase, { session });
      refresh();
    },
    [vault, refresh]
  );

  const setConfig = useCallback(
    async (config: VaultConfig, passphrase: string) => {
      await vault.setConfig(config, passphrase);
      refresh();
    },
    [vault, refresh]
  );

  return { state, canCall: state === "unlocked", unlock, setConfig, refresh };
}

API

new BYOKVault(options?)

Options:

  • namespace?: string storage key prefix (default byok-vault)
  • minPassphraseLength?: number default 8
  • pbkdf2Iterations?: number default 200000
  • maxTokens?: number enables circuit breaker
  • hardMinTokens?: number default 1 when breaker is enabled
  • hardMaxTokens?: number optional ceiling for setMaxTokens(...)
  • devMode?: boolean defaults to NODE_ENV !== "production" when available
  • localStorage?: Storage / sessionStorage?: Storage for testing/custom storage
  • logger?: { warn(message: string): void } custom warning sink
  • passkeyAdapter?: PasskeyAdapter optional WebAuthn adapter override (for custom environments/tests)
  • sessionMode?: "tab" | "action" default unlock persistence mode (tab keeps session unlocked, action requires passphrase/passkey per action)

hardMinTokens / hardMaxTokens require maxTokens to be set.

Methods:

  • setKey(apiKey, passphrase): Promise<void>
  • importKey(plainKey, passphrase, { clearStorageKey?, plainStorage? }?): Promise<void>
  • setConfig(config, passphrase): Promise<void>
  • setConfigWithPasskey(config, options): Promise<void>
  • unlock(passphrase, { session? }): Promise<void>
  • unlockWithPasskey({ timeoutMs?, session? }?): Promise<void>
  • withKey(callback, { requestedTokens?, passphrase?, session? }): Promise<T>
  • withConfig(callback, { requestedTokens?, passphrase?, session? }): Promise<T>
  • withKeyScope(callback, { requestedTokens?, passphrase?, session? }): Promise<T>
  • reportUsage(tokens): void
  • getUsage(): number
  • getRemainingTokens(): number
  • getMaxTokens(): number | null
  • setMaxTokens(limit): void
  • getHardMinTokens(): number | null
  • getHardMaxTokens(): number | null
  • hasStoredKey(): boolean
  • getState(): "none" | "locked" | "unlocked"
  • canCall(): boolean
  • isPasskeyEnrolled(): boolean
  • isLocked(): boolean
  • getEncryptedBlob(): EncryptedKeyBlob | null
  • lock(): void
  • nuke(): void

Error helper exports:

  • getUserMessage(error: unknown): string

setKey / withKey remain compatibility wrappers for key-only workflows.

Threat Model and Limitations

  • JavaScript cannot force immediate memory zeroization of strings; decrypted keys can remain in heap memory until GC.
  • Passphrase quality matters. A short PIN (for example 4 digits) is brute-forceable even with high PBKDF2 iteration counts.
  • PBKDF2 iteration count has a hard floor at 200000; lower values throw at construction time.
  • This package intentionally has zero runtime dependencies, but still has normal dev dependencies for build/test tooling.

Development

npm install
npm run typecheck
npm test
npm run build
npm run pack:check
npm run demo

Sample Project (Gemini)

See examples/local-first-byok-sample/README.md for a separate sample app that uses this package with Gemini API calls.

Human + LLM Docs

  • Human integration guide: docs/HUMANS.md
  • LLM reference: docs/LLMS.md
  • LLM index file: llms.txt