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

@vitalpoint/near-phantom-auth

v0.8.0

Published

Anonymous passkey authentication with NEAR MPC accounts and decentralized recovery

Readme

near-phantom-auth

Drop-in anonymous authentication for any web app — passkeys + NEAR MPC accounts + decentralized recovery, with no email, no phone, and no PII.

Privacy-first: No email, no phone, no PII. Just biometrics and blockchain.

Why use this?

Most "anonymous" auth solutions still ask for an email or phone number for recovery. This package treats anonymity as a hard constraint and ships everything you need to honor it:

  • Truly anonymous sign-up — users register with a passkey (Face ID, Touch ID, Windows Hello). No email, no phone, no real name. Identity is a randomly-generated codename (ALPHA-BRAVO-42) the server cannot link to a person.
  • Per-user NEAR account out of the box — every passkey user gets a deterministic 64-char hex implicit account on NEAR (testnet or mainnet). Optionally auto-funded from your treasury so it is on-chain immediately. The account is the user's, not yours.
  • Account recovery without identity — two recovery paths, both anonymity-preserving:
    • Wallet recovery — link a NEAR wallet on-chain via a FullAccess key. We never see the wallet; the link lives only on the blockchain.
    • Password + IPFS recovery — your password encrypts a recovery blob (AES-256-GCM); we pin the ciphertext to IPFS via Pinata/Web3.Storage/Infura. Lose your device, recover with password + CID.
  • OAuth track for users who want it — Google / GitHub / X-Twitter sign-in is available as a fully separate identity stream that does NOT cross-contaminate the anonymous track. OAuth users live in a different table with a different type.
  • Standalone MPC account helper (v0.6.1+) — if you only need NEAR account provisioning (not the full passkey/recovery stack), import MPCAccountManager directly and skip everything else.
  • End-to-end encryption ready — the WebAuthn PRF extension (v0.6.0+) returns a stable 32-byte sealing key per credential, derived inside the authenticator's secure enclave. Hand it to any DEK provisioner downstream.
  • Production-hardened — Zod input validation on every endpoint, tiered rate limiting, opt-in CSRF, HttpOnly cookies, structured logging with treasury-key redaction, and a 280+ test suite.

Feature reference

  • Passkey Authentication: Face ID, Touch ID, Windows Hello, hardware keys — no passwords
  • NEAR MPC Accounts: User-owned accounts via Chain Signatures (8-node threshold MPC) on testnet or mainnet
  • Standalone MPCAccountManager (v0.6.1+): Provision and recover NEAR accounts without the full auth stack — see MPCAccountManager (v0.6.1+) below
  • Anonymous Identity: Compound codenames (ALPHA-BRAVO-42, SWIFT-FALCON-73) — we never know who you are
  • OAuth Authentication: Google, GitHub, and X/Twitter sign-in (separate identity track that never touches the anonymous user table)
  • Decentralized Recovery:
    • Link a NEAR wallet (on-chain FullAccess access key, not stored in our DB)
    • Password + IPFS backup (encrypted, you hold the keys)
  • HttpOnly Sessions: XSS-proof cookie-based sessions
  • Input Validation: Zod schemas on all 18 endpoints — malformed requests rejected before reaching handlers
  • Rate Limiting: Tiered per-endpoint limits (auth: 20/15min, recovery: 5/hr)
  • CSRF Protection: Opt-in Double Submit Cookie with automatic OAuth callback exemption
  • Structured Logging: Injectable pino logger with sensitive field redaction (treasury private key, etc.); silent by default
  • Automatic Cleanup: Scheduler removes expired sessions, challenges, and OAuth states
  • PRF-Derived Sealing Key (v0.6.0+): WebAuthn PRF extension produces a stable per-credential 32-byte sealing key for end-to-end encryption — opt-in via passkey.prfSalt/requirePrf, graceful degradation on Firefox/older authenticators

WebAuthn PRF Extension (DEK Sealing Key)

Since v0.6.0, the library requests the WebAuthn PRF (Pseudo-Random Function) extension on every registration and login. A PRF-capable authenticator deterministically derives 32 bytes per credential (HMAC-SHA-256 over the RP-supplied salt, computed inside the authenticator's secure enclave). The 32 bytes are hex-encoded as sealingKeyHex and posted in the body of /register/finish and /login/finish. Downstream services (e.g., an auth-service DEK provisioner) can use this stable key material to seal/unseal per-user encrypted data.

The 32 bytes never leave the authenticator's secure enclave in raw form. Only the salt and the derived hex are seen by application code.

Configuration

import { AnonAuthProvider } from '@vitalpoint/near-phantom-auth/client';

function App() {
  return (
    <AnonAuthProvider
      apiUrl="/auth"
      passkey={{
        prfSalt: new TextEncoder().encode('my-app-prf-sealing-v1'),
        requirePrf: false,
      }}
    >
      <AuthDemo />
    </AnonAuthProvider>
  );
}

The same passkey: { prfSalt, requirePrf } shape is also accepted on createAnonAuth({ passkey }) server-side for symmetry with the client surface. On the server this is type documentation only — the library does not use these values at runtime on the server; the salt and enforcement rules live entirely in the browser.

If passkey is omitted, the library defaults to prfSalt = new TextEncoder().encode('near-phantom-auth-prf-v1') and requirePrf = false.

Salt Immutability

  • Do not change the salt after deployment.
  • The PRF output is deterministic over (credential, salt). Changing the salt by one byte produces a different sealing key and makes any data encrypted with the original key inaccessible.
  • The v1 suffix is a rotation identifier, not a semver — it does NOT mean "to be upgraded later." Treat the chosen salt as a permanent constant for the lifetime of the deployment.

Browser Support

| Browser / Authenticator | Registration PRF | Login PRF | Notes | | --------------------------------------------- | ------------------------------------ | --------- | ------------------------------------------------------------------ | | Chrome / Edge ≥116 | yes | yes | iCloud Keychain / Google Password Manager / Chrome 147+ Windows Hello | | Safari ≥18 (iOS 18, macOS 15) | yes | yes | Synced platform passkeys | | Firefox | no | no | PRF not yet implemented as of mid-2025; graceful degradation applies | | Hardware keys (YubiKey, etc.) | no (returns enabled: true only) | yes | First sealing key arrives on first successful login, not registration | | Chrome ≤146 Windows Hello | no | yes | Same hardware-key behavior |

When the authenticator does not return a PRF result, sealingKeyHex is omitted from the POST body (the field is absent, not sent as null). With requirePrf: false (default), the registration/login ceremony completes normally and the user can still use unencrypted features — encrypted endpoints simply 401 until the user logs in again on a PRF-capable device. With requirePrf: true, the register()/login() hook methods throw an Error whose message starts with PRF_NOT_SUPPORTED; the useAnonAuth hook surfaces this as state.error via the existing catch path. Choose requirePrf: true only if your user base is restricted to PRF-capable authenticators — otherwise you will lock out Firefox users entirely.

Migration for Existing Accounts (NULL Key Bundles)

Users who registered before v0.6.0 do not have a DEK provisioned server-side — their account records have a NULL key bundle (users.mlkem_ek IS NULL). Once the auth-service is patched so that provisionUserKeys() fires whenever getUserKeyBundle(userId) returns null on login (not only for brand-new isNewUser registrations), these accounts auto-bootstrap on next successful login. No client-side migration is required: starting at v0.6.0 the library ships sealingKeyHex on every login for PRF-capable authenticators, and the server decides — based on the presence of an existing key bundle — whether to provision a new DEK or unwrap the existing one.

Cross-Domain Passkeys (v0.7.0)

[email protected] adds optional support for the WebAuthn Related Origin Requests feature, letting a single deployment accept passkey assertions from multiple registrable domains (e.g. shopping.com + shopping.co.uk + shopping.de).

What the library does

When rp.relatedOrigins is configured at createAnonAuth() startup, the library:

  1. Validates each entry's shape, scheme (https only, http://localhost permitted only when rpId === 'localhost'), wildcard absence, and suffix-domain pairing at startup. Misconfiguration throws with a classified message — no silent acceptance into production.
  2. Spreads the validated paired tuples into expectedOrigin and expectedRPID on every verifyRegistrationResponse / verifyAuthenticationResponse call, preserving pairing by tuple order.
  3. Caps the list at 5 entries (browser support has a 5-label minimum; additional entries are silently ignored by Chrome/Safari).

What the consumer must do

The library does NOT auto-host /.well-known/webauthn. Hosting is a per-deployment concern — the library cannot see your hosting topology.

Serve a JSON document at https://{primaryRpId}/.well-known/webauthn with Content-Type: application/json listing the related origins. Skeleton:

{
  "origins": [
    "https://shopping.co.uk",
    "https://shopping.ie",
    "https://shopping.ca"
  ]
}

Hosting requirements (per passkeys.dev/docs/advanced/related-origins/):

  • URL: https://{primaryRpId}/.well-known/webauthn (not the related domain)
  • Content-Type: application/json
  • HTTPS only — browsers will not fetch over plain HTTP for non-localhost rpIds
  • The primary RP ID itself MUST NOT be in the array — it's implicit
  • Maximum 5 unique eTLD+1 labels — entries beyond the cap are silently ignored
  • No wildcards in the array

Use your existing static-asset pipeline (Next.js public/, Vercel/Cloudflare static assets, S3+CloudFront, etc.) — the library does not own request routing for /.well-known/* paths.

Browser support

Cross-domain passkey support shipped in Chrome 128 (Aug 2024) and Safari 18 (Sep 2024). Firefox users see SecurityError on registrations that span domains; the recommended graceful degradation is for Firefox users to register a separate passkey per domain.

Server-side configuration

Pass rp.relatedOrigins to createAnonAuth() as an array of paired tuples (NOT two parallel arrays — see security note below):

import { createAnonAuth } from '@vitalpoint/near-phantom-auth/server';

const auth = createAnonAuth({
  // ... other config ...
  rp: {
    name: 'My App',
    id: 'shopping.com',
    origin: 'https://shopping.com',
    relatedOrigins: [
      { origin: 'https://shopping.co.uk', rpId: 'shopping.co.uk' },
      { origin: 'https://shopping.ie',    rpId: 'shopping.ie' },
    ],
  },
});

Security: paired tuple vs parallel arrays

The library uses an Array<{ origin, rpId }> paired-tuple shape — NOT two parallel arrays — because @simplewebauthn/server does not cross-check origin↔rpId pairing. It tests independent membership of each list. If your config drifted (e.g. via a .map() reorder of one array), the library would accept assertions where originA was signed under rpIdB, even though that combination has no allowlist relationship.

The paired-tuple shape makes pairing intent structural — it cannot be silently broken by a refactor because the array IS the list of pairs.

References

Second-Factor Enrolment Hook (v0.7.0)

[email protected] exposes hooks.afterAuthSuccess — an inline, blocking hook that fires AFTER auth succeeds (passkey verify + DB persist

  • MPC funding) but BEFORE session creation. The hook lets consumers gate session issuance on a second factor (TOTP, push notification, hardware token, manual approval, etc.) without forcing the library into the secret-storage business.

What the library does

The library invokes your hook at five fire points:

  1. POST /register/finish — after passkey verify + mpcManager.createAccount
    • db.createUser + db.createPasskey, BEFORE sessionManager.createSession. Fires INSIDE the db.transaction() wrapper — a hook throw rolls back the DB rows. (src/server/router.ts)
  2. POST /login/finish — after passkey verify + db.getUserById, BEFORE sessionManager.createSession. NO transaction wrapper on this path — a hook throw produces a 500 but no DB rollback is needed (the passkey-counter update already committed by passkeyManager.finishAuthentication must persist for replay protection). (src/server/router.ts)
  3. POST /oauth/:provider/callback — three success branches:
    • Existing user, same provider — fires after db.getOAuthUserByProvider.
    • Existing user, link by email — fires after db.linkOAuthProvider.
    • New user — fires after mpcManager.createAccount + db.createOAuthUser
      • IPFS recovery setup. NO transaction wrapper on any OAuth branch. (src/server/oauth/router.ts)

The hook receives a discriminated-union context:

export type AfterAuthSuccessProvider = 'google' | 'github' | 'twitter';

export type AfterAuthSuccessCtx =
  | { authMethod: 'passkey-register'; userId: string; codename: string;
      nearAccountId: string; req: express.Request }
  | { authMethod: 'passkey-login';    userId: string; codename: string;
      nearAccountId: string; req: express.Request }
  | { authMethod: 'oauth-google' | 'oauth-github' | 'oauth-twitter';
      userId: string; codename?: string; nearAccountId: string;
      provider: AfterAuthSuccessProvider; req: express.Request };

The hook returns:

type AfterAuthSuccessResult =
  | { continue: true }
  | { continue: false; status: number; body: Record<string, unknown> };

On continue: true, the library proceeds with sessionManager.createSession and the standard response. On continue: false, the library spreads consumer's body into the response, echoes a structured secondFactor: { status, body } field, and DOES NOT create a session (no Set-Cookie header is emitted).

Hook ctx surfaces userId, codename, and nearAccountId to your code — the library does NOT log or telemetrize these fields (anonymity invariant).

What the consumer must do

Wire the hook on createAnonAuth({ ..., hooks: { afterAuthSuccess: ... } }). Inside, use the discriminator to narrow:

afterAuthSuccess: async (ctx) => {
  if (ctx.authMethod === 'passkey-register') {
    // Maybe: enqueue 2FA enrolment ceremony
    return { continue: false, status: 202, body: { totpUri: '...' } };
  }
  if (ctx.authMethod === 'passkey-login') {
    // Verify a TOTP code already submitted by the client (e.g., in req.body.totp).
    const totp = (ctx.req.body as any).totp;
    if (!totp || !(await verifyTotp(ctx.userId, totp))) {
      return { continue: false, status: 401, body: { error: 'TOTP required' } };
    }
  }
  if (ctx.authMethod.startsWith('oauth-')) {
    // ctx.provider is now narrowed to AfterAuthSuccessProvider.
    // ctx.codename is OPTIONAL on OAuth — OAuthUser does not carry a codename in v0.7.0.
  }
  return { continue: true };
}

ctx.req is the bare Express Request — it carries cookies, headers, body, etc. The library does NOT sanitize this surface; what your hook reads from req is your responsibility.

MPC orphan trade-off (HOOK-06)

mpcManager.createAccount runs BEFORE the database transaction opens on /register/finish (and outside any transaction wrapper on the OAuth new-user branch). A hook throw OR a continue: false AFTER MPC funding leaves an orphaned funded NEAR implicit account with no DB record. The library cannot recover the on-chain funds.

Mitigation: make your hook idempotent and non-throwing. Prefer { continue: false, status, body } over throw new Error(...) for soft failures (TOTP not yet submitted, push notification timeout, etc.). A returned continue: false does not roll back the DB — it commits the user + passkey rows but skips session creation. Your subsequent retry can use the same userId to complete enrolment.

OAuth Branch 3 (new user) extends the trade-off: because OAuth has no transaction wrapper at all, a continue: false on the new-user branch leaves the oauth_users row, the MPC account, AND the IPFS recovery blob all committed. Same mitigation applies.

Cookie semantics

On continue: false, the response carries NO live Set-Cookie header — the session was never created. (The OAuth callback always emits expired oauth_state / oauth_code_verifier clear-cookie hygiene, which is NOT a session cookie.) Your client should detect short-circuit by inspecting response.body.secondFactor (the structured echo) and initiate the second-factor enrolment ceremony you specified in body.

References

  • REQUIREMENTS: HOOK-02..06 in .planning/REQUIREMENTS.md
  • Type definitions: src/types/index.ts (search for AfterAuthSuccessCtx)
  • Re-export surface: import type { AfterAuthSuccessCtx, AfterAuthSuccessResult, AfterAuthSuccessProvider } from '@vitalpoint/near-phantom-auth/server';

Lazy-Backfill Hook (v0.7.0)

[email protected] exposes hooks.backfillKeyBundle — a pass-through hook that fires inside POST /login/finish when the authenticator returned a fresh PRF sealing key (sealingKeyHex). The hook lets consumers run their own key-bundle migration ceremony for pre-v0.6.0 NULL-bundle accounts without the library taking any opinion on the consumer's schema, transaction boundaries, or recovery topology.

The contract is deliberately narrow: the library hands the consumer the minimal context (userId, codename, nearAccountId, sealingKeyHex, req), invokes the hook, echoes the result on the response, and otherwise steps aside. Backfill failure NEVER blocks login. A hook throw is caught, logged at WARN with a redacted payload, and the response continues with backfill: { backfilled: false, reason: 'skipped' }.

What the library does

The library invokes the hook at exactly one fire point:

  • POST /login/finish — after passkey verify + db.getUserById, after any Phase 14 hooks.afterAuthSuccess that returned continue: true, BEFORE sessionManager.createSession. It fires only when sealingKeyHex was supplied in the request body. No PRF means no fresh sealing key, so the hook is silently skipped and no backfill field appears on the response.

Sequential ordering when both Phase 14 and Phase 15 hooks are configured:

  1. hooks.afterAuthSuccess runs first.
  2. If it returns { continue: false, ... }, the response short-circuits and the backfill hook is not invoked.
  3. If it returns { continue: true } (or is absent), and sealingKeyHex is present, and hooks.backfillKeyBundle exists, the backfill hook fires.
  4. The result is echoed on the login response under an additive backfill key, alongside the existing success, codename, and passkey? fields.

The hook receives:

import type { Request } from 'express';

export interface BackfillKeyBundleCtx {
  userId: string;
  codename: string;
  nearAccountId: string;
  sealingKeyHex: string;
  req: Request;
}

The hook returns:

export type BackfillReason =
  | 'already-current'
  | 'no-legacy-data'
  | 'completed'
  | 'skipped';

export interface BackfillKeyBundleResult {
  backfilled: boolean;
  reason?: BackfillReason;
}

Example login response when the hook runs:

{
  "success": true,
  "codename": "ALPHA-BRAVO-7",
  "passkey": { "backedUp": false, "backupEligible": false },
  "backfill": { "backfilled": true, "reason": "completed" }
}

What the consumer must do

Implement the migration in your own application code:

hooks: {
  backfillKeyBundle: async (ctx) => {
    const legacy = await legacyStore.getNullBundleRow(ctx.userId);
    if (!legacy) return { backfilled: false, reason: 'no-legacy-data' };

    const bundle = await deriveKeyBundleFromSealingKey(ctx.sealingKeyHex);

    await appDb.transaction(async (tx) => {
      await tx.keyBundles.upsert({
        userId: ctx.userId,
        encryptedBundle: bundle.encryptedBundle,
        wrappedDek: bundle.wrappedDek,
      });
      await tx.legacyBundles.markMigrated(ctx.userId);
    });

    return { backfilled: true, reason: 'completed' };
  },
}

ctx.req is the bare Express Request. The library does not sanitize it. If your hook reads cookies, headers, or extra body fields from req, that is your responsibility.

Consumer-owns-schema contract

The library does not persist key bundles. It does not add schema, run schema migrations, or choose how your application stores encrypted material.

The library also does not run a transaction around the hook. If your migration touches multiple tables, you must provide your own transaction discipline. That is the point of the pass-through design: the library provides the auth lifecycle fire point, but the consumer owns the data model.

BACKFILL-03 is load-bearing here: if your hook throws, the library catches the error, logs a redacted WARN entry, and still completes login. The fallback response is:

{
  "backfill": { "backfilled": false, "reason": "skipped" }
}

Dual-recovery + IPFS-orphan footnote

The library does not migrate existing IPFS recovery blobs. Those blobs remain consumer-owned. If your backfill replaces the recovery method, an older IPFS recovery blob may still exist but no longer be referenced by your current recovery path.

That creates an explicit dual-recovery state:

  • your new key-bundle recovery path may be current
  • the old IPFS recovery blob may still exist
  • the library will not reconcile or delete it for you

If you want old blobs cleaned up, documented, or kept as a temporary fallback, handle that in your own migration logic.

Known limitation

The Phase 15 hook is awaited inline and currently has no library-side timeout. A hook that hangs will delay login. Consumer hooks must resolve in finite time. Timeout policy is deferred to a later release.

References

  • REQUIREMENTS: BACKFILL-01..04 in .planning/REQUIREMENTS.md
  • Type definitions: src/types/index.ts (search for BackfillKeyBundleCtx)
  • Re-export surface: import type { BackfillKeyBundleCtx, BackfillKeyBundleResult, BackfillReason } from '@vitalpoint/near-phantom-auth/server';

Hooks (v0.7.0)

v0.7.0 adds consumer extension points without removing or renaming the v0.6.1 API surface. The release is additive: existing passkey, recovery, OAuth, PRF, and MPCAccountManager consumers continue to compile while new consumers can opt into hooks and cross-domain passkey configuration.

| Surface | Purpose | Key contract | |---------|---------|--------------| | hooks.afterAuthSuccess | Inline second-factor gating after auth succeeds and before session creation. | Handles passkey register, passkey login, and OAuth callback success. Returning { continue: false, status, body } short-circuits and echoes secondFactor. See Second-Factor Enrolment Hook (v0.7.0). | | hooks.backfillKeyBundle | Pass-through lazy key-bundle migration for pre-v0.6.0 NULL-bundle accounts. | Fires only on /login/finish when sealingKeyHex exists. The library follows a consumer-owned schema contract: it does not persist bundles, wrap a transaction, or migrate IPFS blobs. See Lazy-Backfill Hook (v0.7.0). | | hooks.onAuthEvent | Privacy-preserving lifecycle analytics. | Event shapes enforce the anonymity invariant: no userId, codename, nearAccountId, email, raw IP, or raw user-agent fields. Default is fire-and-forget; set awaitAnalytics: true when the event must complete before the response. | | rp.relatedOrigins | Cross-domain passkeys via WebAuthn Related Origin Requests. | Use paired tuples ({ origin, rpId }) with a maximum of 5 related origins. The library validates scheme, wildcard absence, suffix-domain pairing, and cap at startup. See Cross-Domain Passkeys (v0.7.0). |

Important release notes:

  • MPC orphan trade-off: hooks.afterAuthSuccess runs after MPC funding on registration and OAuth new-user paths. Hook failures or soft short-circuits can leave funded on-chain accounts or committed OAuth/IPFS state; use idempotent hooks and prefer returned { continue: false } for soft failures.
  • consumer-owned schema: hooks.backfillKeyBundle gives your code sealingKeyHex and user identifiers, then steps aside. Your application owns the schema, transaction, migration, and any cleanup for old IPFS recovery blobs.
  • anonymity invariant: library analytics events and hook error logs never include user identifiers or PRF sealing material. Consumer hooks receive sensitive context intentionally and are responsible for their own handling.
  • 5 related origins: browsers cap related-origin passkey support; the library enforces a maximum of 5 related origins and does not auto-host /.well-known/webauthn.

Enterprise Identity Module (v0.8.x)

The enterprise module is opt-in and off by default. If enterprise config is absent, createAnonAuth() exposes no enterprise API, no scimRouter, no enterprise database initialization, and no enterprise middleware lookup. The anonymous passkey track remains the default package behavior.

Enterprise identity binding lets an organization bind an external IdP subject from Okta, Entra, PingFederate, Google Workspace, or another IdP to the same NEAR DID/MPC account model used elsewhere in the package.

import { createAnonAuth } from '@vitalpoint/near-phantom-auth/server';

const auth = createAnonAuth({
  // existing config unchanged
  nearNetwork: 'testnet',
  sessionSecret: process.env.SESSION_SECRET!,
  database: { type: 'postgres', connectionString: process.env.DATABASE_URL! },
  enterprise: {
    enabled: true,
    binding: { mintMpcIfMissing: true },
    passkeyStepUp: false,
    serverManagedDek: true,
    scim: {
      enabled: true,
      bearerToken: process.env.SCIM_BEARER_TOKEN!,
      attributeMapping: {
        userName: 'email',
        'name.formatted': 'displayName',
      },
    },
  },
});

if (auth.scimRouter) {
  app.use('/scim/v2', auth.scimRouter);
}

The binding API is available as auth.enterprise only when enterprise is enabled:

await auth.enterprise?.linkIdentity({
  externalIdp: 'okta',
  externalSub: '00u123',
  externalAttrs: { email: '[email protected]' },
});

await auth.enterprise?.unlinkIdentity({
  externalIdp: 'okta',
  externalSub: '00u123',
  mode: 'revoke',
});

const binding = await auth.enterprise?.resolveByExternalId('okta', '00u123');

SCIM is protected by the bearer token you configure. The package validates that token on every SCIM request; it does not issue or rotate the token. SCIM active:false and DELETE revoke the enterprise binding and invalidate live enterprise sessions.

The package provides mechanism, not product policy. It stores bindings, handles SCIM lifecycle, emits enterprise lifecycle events, and reuses NEAR MPC account minting. Your application still owns role-to-permission mapping, dashboard scopes, audit log format and sink, "disable anonymity in mode X" decisions, and government smartcard specifics. Generic OIDC and SAML connectors remain promote-later work after real-tenant hardening.

Enterprise PRF and DEK Modes

Enterprise IdP authentication alone does not produce a WebAuthn PRF sealing key. Use enterprise.passkeyStepUp: true when enterprise users must register or use a passkey after IdP auth so downstream systems can rely on authenticator-rooted PRF material. Use enterprise.serverManagedDek: true when your deployment provisions the user's DEK from a server- or enclave-held key hierarchy instead. Pure IdP enterprise auth without passkey step-up does not have the same authenticator-rooted DEK property as passkey users.

Installation

npm install @vitalpoint/near-phantom-auth

The package provides both server and client exports:

  • @vitalpoint/near-phantom-auth/server - Express router, session management, MPC accounts
  • @vitalpoint/near-phantom-auth/client - React hooks, WebAuthn helpers, API client

Both are included in the single package - no separate installs needed.

Quick Start

Server (Express)

import express from 'express';
import { createAnonAuth } from '@vitalpoint/near-phantom-auth/server';

const app = express();

const auth = createAnonAuth({
  nearNetwork: 'testnet',
  sessionSecret: process.env.SESSION_SECRET!,
  database: {
    type: 'postgres',
    connectionString: process.env.DATABASE_URL!,
  },
  rp: {
    name: 'My App',
    id: 'myapp.com',
    origin: 'https://myapp.com',
  },
  // Recommended for production: prevents account ID prediction
  derivationSalt: process.env.DERIVATION_SALT!,
  // Recommended for maximum anonymous-track privacy: omit session metadata
  sessionMetadata: {
    ipAddress: 'omit',
    userAgent: 'omit',
  },
  recovery: {
    wallet: true,
    ipfs: {
      pinningService: 'pinata',
      apiKey: process.env.PINATA_API_KEY,
      apiSecret: process.env.PINATA_API_SECRET,
    },
  },
});

// Initialize database schema
await auth.initialize();

// Mount auth routes
app.use('/auth', auth.router);

// Mount OAuth routes (optional)
if (auth.oauthRouter) {
  app.use('/auth/oauth', auth.oauthRouter);
}

// Protect routes
app.get('/api/me', auth.requireAuth, (req, res) => {
  res.json({
    codename: req.anonUser!.codename,
    nearAccountId: req.anonUser!.nearAccountId,
  });
});

app.listen(3000);

Client (React)

import { AnonAuthProvider, useAnonAuth } from '@vitalpoint/near-phantom-auth/client';

function App() {
  return (
    <AnonAuthProvider apiUrl="/auth">
      <AuthDemo />
    </AnonAuthProvider>
  );
}

function AuthDemo() {
  const {
    isLoading,
    isAuthenticated,
    codename,
    nearAccountId,
    webAuthnSupported,
    register,
    login,
    logout,
    error,
    clearError,
  } = useAnonAuth();

  if (isLoading) return <div>Loading...</div>;

  if (!webAuthnSupported) {
    return <div>Your browser doesn't support passkeys.</div>;
  }

  if (!isAuthenticated) {
    return (
      <div>
        <h1>Anonymous Auth Demo</h1>
        {error && (
          <p style={{ color: 'red' }}>
            {error} <button onClick={clearError}>x</button>
          </p>
        )}
        <button onClick={register}>Register (Create Identity)</button>
        <button onClick={() => login()}>Sign In (Existing Identity)</button>
      </div>
    );
  }

  return (
    <div>
      <h1>Welcome, {codename}</h1>
      <p>NEAR Account: {nearAccountId}</p>
      <button onClick={logout}>Sign Out</button>
    </div>
  );
}

Important: Always use the client library's register and login functions rather than implementing WebAuthn manually. WebAuthn uses base64url encoding (not standard base64), and the client library handles this correctly.

Client (Vanilla JS / Non-React)

For non-React applications, use the lower-level functions:

import {
  createApiClient,
  createPasskey,
  authenticateWithPasskey,
  isWebAuthnSupported
} from '@vitalpoint/near-phantom-auth/client';

const api = createApiClient({ baseUrl: '/auth' });

// Check support
if (!isWebAuthnSupported()) {
  console.error('WebAuthn not supported');
}

// Register
async function register() {
  const { challengeId, options, tempUserId, codename } = await api.startRegistration();
  const credential = await createPasskey(options); // Handles base64url encoding
  const result = await api.finishRegistration(challengeId, credential, tempUserId, codename);
  console.log('Registered as:', result.codename);
}

// Login
async function login() {
  const { challengeId, options } = await api.startAuthentication();
  const credential = await authenticateWithPasskey(options); // Handles base64url encoding
  const result = await api.finishAuthentication(challengeId, credential);
  console.log('Logged in as:', result.codename);
}

How It Works

Registration Flow

1. User clicks "Register"
2. Browser creates passkey (biometric prompt)
3. Server creates NEAR account via MPC
4. User gets compound codename (e.g., ALPHA-BRAVO-42)
5. Session cookie set (HttpOnly, Secure, SameSite=Strict)

Authentication Flow

1. User clicks "Sign In"
2. Browser prompts for passkey (biometric)
3. Server verifies signature (constant-time comparison)
4. Session cookie set

Recovery Options

Wallet Recovery

  • User links existing NEAR wallet
  • Wallet added as on-chain access key (NOT stored in our database)
  • Recovery: Sign with wallet -> Create new passkey

Password + IPFS Recovery

  • User sets strong password
  • Recovery data encrypted with password (AES-256-GCM)
  • Encrypted blob stored on IPFS via concurrent multi-gateway pinning
  • User saves: password + IPFS CID
  • Recovery: Provide password + CID -> Decrypt -> Create new passkey

MPCAccountManager (v0.6.1+)

Standalone helper for provisioning NEAR implicit accounts and verifying recovery wallets. Exported from @vitalpoint/near-phantom-auth/server as a runtime value. Use this directly when you only need the account-provisioning pipeline — not the full passkey + session + recovery stack — for example when integrating with an existing auth service via a sidecar.

When to use it

| Use case | Use this | Use full createAnonAuth | |----------|----------|---------------------------| | Building a complete anonymous auth flow (passkey + sessions + recovery) | — | yes | | Provisioning NEAR accounts for users authenticated by another system | yes | — | | Server-to-server account creation triggered by a webhook | yes | — | | Verifying that a wallet has FullAccess on a user's NEAR account | yes | yes (via auth.mpc.verifyRecoveryWallet) | | Idempotent retry of provisioning from a queue worker | yes | — |

Quick start

import {
  MPCAccountManager,
  type MPCAccountManagerConfig,
  type CreateAccountResult,
} from '@vitalpoint/near-phantom-auth/server';

const manager = new MPCAccountManager({
  networkId: 'testnet',                                    // or 'mainnet'
  treasuryAccount: process.env.NEAR_TREASURY_ACCOUNT!,
  treasuryPrivateKey: process.env.NEAR_TREASURY_KEY!,
  derivationSalt: process.env.NEAR_DERIVATION_SALT!,        // REQUIRED — see Security
  fundingAmount: '0.01',                                   // optional; default '0.01' NEAR
});

const result: CreateAccountResult = await manager.createAccount('user-id');
// result.nearAccountId  matches /^[a-f0-9]{64}$/  (64-char hex implicit account)
// result.mpcPublicKey   is `ed25519:${bs58.encode(publicKeyBytes)}`
// result.derivationPath is `near-anon-auth,user-id`
// result.onChain        is true after successful funding

Derivation function

The account ID is a pure function of (derivationSalt, userId) — same arguments always produce the same account. There is no randomness; idempotent retry is safe.

seedInput      = `implicit-${derivationSalt}-${userId}`
seed           = SHA-256(seedInput)
publicKeyBytes = first 32 bytes of SHA-512(seed)
nearAccountId  = publicKeyBytes.toString('hex')         // 64-char lowercase hex
mpcPublicKey   = `ed25519:${bs58.encode(publicKeyBytes)}`
derivationPath = `near-anon-auth,${userId}`

If derivationSalt is omitted (only possible via the looser internal MPCConfig type), account IDs become predictable from user IDs alone — the standalone MPCAccountManagerConfig type makes the salt REQUIRED at compile time.

Idempotency (MPC-03)

createAccount(userId) is idempotent. A second call against an already-provisioned account short-circuits via view_account and issues zero additional broadcast_tx_commit calls — the existing on-chain account is returned with onChain: true.

Concurrent calls (MPC-06)

Two concurrent createAccount calls for the same userId from different replicas converge to a single provisioned account. The loser of the nonce race retries view_account once and returns success when the winner has already provisioned the account.

Error paths (MPC-10)

createAccount throws when:

| Condition | Thrown error | Suggested HTTP status | |-----------|--------------|-----------------------| | NEAR RPC is unreachable (fetch throws) | Error('RPC unreachable', { cause }) | 503 | | Treasury balance is too low | Error('Treasury underfunded', { cause }) | 503 | | Any other broadcast failure | Error('Transfer failed', { cause }) | 502 |

The cause field always contains the original RPC error message for debugging.

verifyRecoveryWallet throws only when the NEAR RPC is unreachable (consumer should return 500). It returns false (does not throw) for missing accounts, FunctionCall-only keys, or unknown access keys.

Security expectations

  • derivationSalt is REQUIRED at the type level — TypeScript rejects an MPCAccountManagerConfig literal that omits it. Use a per-tenant secret salt to prevent cross-tenant account ID collision.
  • treasuryPrivateKey is never logged — the manager replaces the raw string with a KeyPair object on construction. The default-silent pino logger is wired with redact paths (config.treasuryPrivateKey, *.treasuryPrivateKey); even an accidental log.info({ config }, '...') emits [Redacted] instead of the secret.
  • Transactions are signed in-process — no near-cli shell-out, no process.exec injection vector.
  • verifyRecoveryWallet returns true ONLY for FullAccess keys — FunctionCall-scoped keys (which cannot sign arbitrary transactions) cannot satisfy recovery verification.
  • Dist bundle is leak-audited — the published dist/server/index.js is checked at build time to confirm zero ed25519:<base58> string literals are baked in.

Frozen contract (consumer pin)

The following surface is FROZEN — no field, method, or return-shape rename without a coordinated PR:

  • class MPCAccountManager
  • createAccount(userId: string): Promise<CreateAccountResult>
  • verifyRecoveryWallet(nearAccountId: string, recoveryWalletPublicKey: string): Promise<boolean>
  • interface MPCAccountManagerConfig (with derivationSalt: string REQUIRED)
  • type CreateAccountResult (= MPCAccount)

API Routes

Passkey Authentication

| Method | Route | Description | |--------|-------|-------------| | POST | /register/start | Start passkey registration | | POST | /register/finish | Complete registration, create NEAR account | | POST | /login/start | Start authentication | | POST | /login/finish | Complete authentication | | POST | /logout | End session | | GET | /session | Get current session | | GET | /csrf-token | Get CSRF token (when CSRF enabled) |

Recovery

| Method | Route | Description | |--------|-------|-------------| | POST | /recovery/wallet/link | Start wallet linking | | POST | /recovery/wallet/verify | Complete wallet linking | | POST | /recovery/wallet/start | Start wallet recovery | | POST | /recovery/wallet/finish | Complete wallet recovery | | POST | /recovery/ipfs/setup | Create IPFS backup | | POST | /recovery/ipfs/recover | Recover from IPFS |

Account Management

| Method | Route | Description | |--------|-------|-------------| | POST | /account/reregister-passkey | Re-register passkey after recovery | | DELETE | /account | Delete account and all associated data |

OAuth (mounted separately)

| Method | Route | Description | |--------|-------|-------------| | GET | /oauth/providers | List available OAuth providers | | GET | /oauth/:provider/start | Start OAuth flow (google, github, twitter) | | POST | /oauth/:provider/callback | Handle OAuth callback | | POST | /oauth/:provider/link | Link additional provider to account |

Configuration

Full Configuration

const auth = createAnonAuth({
  // === Required ===
  nearNetwork: 'testnet',           // 'testnet' or 'mainnet'
  sessionSecret: '...',             // Session signing secret
  database: {
    type: 'postgres',               // 'postgres' or 'custom'
    connectionString: '...',        // PostgreSQL connection string
    // OR
    // type: 'custom',
    // adapter: myCustomAdapter,    // DatabaseAdapter implementation
  },

  // === WebAuthn Relying Party ===
  rp: {
    name: 'My App',
    id: 'myapp.com',
    origin: 'https://myapp.com',
    // Optional v0.7.0 (RPID-01): cross-domain passkey support via WebAuthn
    // Related Origin Requests. Paired tuples — NOT two parallel arrays.
    // Max 5 entries. See "Cross-Domain Passkeys (v0.7.0)" below.
    // relatedOrigins: [
    //   { origin: 'https://myapp.co.uk', rpId: 'myapp.co.uk' },
    //   { origin: 'https://myapp.de',    rpId: 'myapp.de' },
    // ],
  },

  // === Privacy (recommended for production) ===
  derivationSalt: '...',           // Prevents NEAR account ID prediction

  // === Session ===
  sessionDurationMs: 7 * 24 * 60 * 60 * 1000, // Default: 7 days
  sessionMetadata: {
    ipAddress: 'omit',             // 'store' (default), 'omit', 'hash', or 'truncate'
    userAgent: 'omit',             // 'store' (default), 'omit', or 'hash'
  },

  // === Codename Generation ===
  codename: {
    style: 'nato-phonetic',         // ALPHA-BRAVO-42 (default)
    // style: 'animals',            // SWIFT-FALCON-73
    // generator: (userId) => `AGENT-${userId.slice(0, 8)}`, // Custom
  },

  // === Recovery ===
  recovery: {
    wallet: true,                   // Enable on-chain wallet recovery
    ipfs: {                         // Enable IPFS + password recovery
      pinningService: 'pinata',     // 'pinata', 'web3storage', 'infura', 'custom'
      apiKey: '...',
      apiSecret: '...',
    },
  },

  // === OAuth (optional) ===
  oauth: {
    callbackBaseUrl: 'https://myapp.com/auth/callback',
    google: { clientId: '...', clientSecret: '...' },
    github: { clientId: '...', clientSecret: '...' },
    twitter: { clientId: '...', clientSecret: '...' },
  },

  // === MPC Account Config (optional) ===
  mpc: {
    treasuryAccount: 'your-treasury.near',
    treasuryPrivateKey: process.env.NEAR_TREASURY_PRIVATE_KEY,
    fundingAmount: '0.01',          // NEAR per new account
    accountPrefix: 'anon',          // Account name prefix
    derivationSalt: '...',          // Alternative to top-level derivationSalt
  },

  // === Logging (optional) ===
  logger: pinoInstance,             // pino logger; silent/disabled if omitted

  // === Rate Limiting (optional, sensible defaults) ===
  rateLimiting: {
    auth: { windowMs: 900000, limit: 20 },      // 20 req / 15 min
    recovery: { windowMs: 3600000, limit: 5 },   // 5 req / 1 hr
  },

  // === CSRF Protection (optional, disabled by default) ===
  csrf: {
    secret: '...',                  // HMAC secret (must differ from sessionSecret)
  },

  // === Email (optional, for OAuth recovery passwords) ===
  email: {
    region: 'us-east-1',           // AWS SES region
    accessKeyId: '...',            // Optional (uses instance profile if omitted)
    secretAccessKey: '...',
    fromAddress: '[email protected]',
  },
});

Environment Variables

# Required
SESSION_SECRET=your-secure-session-secret
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb

# Privacy (recommended for production)
DERIVATION_SALT=your-random-secret-salt

# NEAR Network ('testnet' or 'mainnet')
NEAR_NETWORK=mainnet

# Mainnet: Treasury for auto-funding new accounts
NEAR_TREASURY_ACCOUNT=your-treasury.near
NEAR_TREASURY_PRIVATE_KEY=ed25519:5abc123...
NEAR_FUNDING_AMOUNT=0.01  # optional, default 0.01

# Optional: Recovery via IPFS (Pinata)
PINATA_API_KEY=your-pinata-key
PINATA_API_SECRET=your-pinata-secret

# Optional: Recovery via IPFS (Web3.Storage)
WEB3_STORAGE_TOKEN=your-web3storage-token

# Optional: Recovery via IPFS (Infura)
INFURA_IPFS_PROJECT_ID=your-project-id
INFURA_IPFS_PROJECT_SECRET=your-project-secret

# Optional: OAuth providers
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
TWITTER_CLIENT_ID=...
TWITTER_CLIENT_SECRET=...

# Optional: CSRF protection
CSRF_SECRET=your-csrf-secret

# Optional: AWS SES email
AWS_SES_REGION=us-east-1
AWS_SES_ACCESS_KEY_ID=...
AWS_SES_SECRET_ACCESS_KEY=...
[email protected]

Cleanup Scheduler

Expired sessions, challenges, and OAuth states are not cleaned automatically by createAnonAuth. Use the standalone cleanup scheduler:

import { createCleanupScheduler } from '@vitalpoint/near-phantom-auth/server';

const scheduler = createCleanupScheduler(auth.db, logger, 5 * 60 * 1000); // every 5 min

// On graceful shutdown:
process.on('SIGTERM', () => scheduler.stop());

MPC Account Funding (Mainnet)

On NEAR mainnet, implicit accounts (64-char hex addresses) need initial funding to become active on-chain. Configure a treasury account to auto-fund new user accounts:

const auth = createAnonAuth({
  nearNetwork: 'mainnet',
  // ... other config

  mpc: {
    treasuryAccount: 'your-treasury.near',
    treasuryPrivateKey: process.env.NEAR_TREASURY_PRIVATE_KEY,
    fundingAmount: '0.01',
    accountPrefix: 'myapp',
  },
});

How it works:

  1. New user registers with passkey
  2. System derives deterministic implicit account ID (64-char hex)
  3. Treasury sends 0.01 NEAR to activate the account
  4. User can now receive/send NEAR immediately

Cost estimation:

  • ~0.01 NEAR per new user
  • 1 NEAR funds ~100 new accounts
  • Treasury account needs ~0.00182 NEAR minimum balance to stay active

Testnet: On testnet, accounts are auto-created via the NEAR testnet helper API with test tokens. No treasury needed.

Security Recommendations

Hardware Security Keys

For maximum security, we recommend using a hardware security key instead of platform authenticators (Face ID, fingerprint). Hardware keys provide:

  • Phishing resistance: Credentials bound to specific domains
  • No biometric data exposure: Key never leaves the device
  • Cross-device portability: Use the same key on multiple devices
  • Air-gapped signing: Private keys never touch your computer

Recommended: Nitrokey - Open source hardware security keys with FIDO2/WebAuthn support. Made in Germany with open firmware you can audit.

Production Checklist

  • [ ] Set derivationSalt to prevent NEAR account ID prediction
  • [ ] Set sessionSecret to a cryptographically random value (32+ bytes)
  • [ ] Enable csrf with a separate secret if your frontend is on a different origin
  • [ ] Configure rateLimiting thresholds appropriate for your traffic
  • [ ] Set sessionMetadata to omit or hash IP/user-agent data if your threat model treats operational metadata as identifying
  • [ ] Provide a logger instance with appropriate redaction for your environment
  • [ ] Run createCleanupScheduler to prevent expired record accumulation

Privacy and Anonymity Audit

This section documents exactly what the package stores, logs, and exposes for passkey (anonymous) users. The goal: it must be impossible to link a passkey user to a real-world identity through anything this package does. Enterprise identity is a separate opt-in track; it does not change anonymous defaults.

What We Store (Passkey Users)

| Data | Stored | Location | Identity Risk | |------|--------|----------|---------------| | Email | No | - | - | | Phone | No | - | - | | Real name | No | - | - | | Codename | Yes | Database | None - randomly generated from crypto.randomBytes() | | NEAR account ID | Yes | Database + Blockchain | None - derived from random UUID + salt | | Passkey public key | Yes | Database | None - device-generated, unlinkable | | Session IP address | Configurable | Database (anon_sessions) | Raw values only when sessionMetadata.ipAddress uses store; otherwise omitted, HMAC-hashed, or coarse-truncated | | Session user agent | Configurable | Database (anon_sessions) | Raw values only when sessionMetadata.userAgent uses store; otherwise omitted or HMAC-hashed | | Recovery wallet link | No | On-chain only | - | | IPFS backup CID | Yes | Database | None - content is AES-256-GCM encrypted |

What We Cannot Know

  • Real identity of passkey users - no PII is collected or stored at any point in the passkey flow
  • Link between codename and real person - codenames are random, not derived from identity
  • Link between NEAR account and real person - account IDs are derived from random UUIDs (with derivationSalt, they are also unpredictable)
  • Contents of IPFS recovery backups - encrypted with user-chosen password, never stored server-side
  • Which wallet belongs to which user - recovery wallet linkage is on-chain only, not in our database

Anonymity Design Decisions

WebAuthn attestation is set to 'none'. The package never requests device attestation, which means the server never learns the manufacturer, model, or firmware version of the user's authenticator. This is intentional - attestation is an identity vector.

OAuth and passkey tracks are fully separated. OAuth users (who have email/name) and passkey users (who are anonymous) are stored in separate database tables (oauth_users vs anon_users) with separate TypeScript types. OAuth identity data never leaks into anonymous user records.

Enterprise identity is a third opt-in track. Enterprise users live in enterprise_users, not anon_users or oauth_users. External IdP subjects, SCIM ids, and chosen externalAttrs stay on that enterprise track. The package does not join enterprise_users into anon_users, does not copy enterprise PII into anonymous sessions or anonymous route responses, and does not add enterprise identifiers to AnalyticsEvent.

Codenames are purely random. Generated from crypto.randomBytes() selecting from word lists. Not derived from user ID, device, IP, or any other input. Two users on the same device get unrelated codenames.

Logging is silent by default. If no logger is provided, zero output is produced. When logging is enabled, passkey flow log calls contain no identity data - only error objects, NEAR account IDs, and operational metadata.

Rate limiting is in-memory only. IP addresses are used as rate limit keys by express-rate-limit but are never persisted to disk or database by the rate limiter. They exist only in the Node.js process memory for the duration of the rate limit window.

Session Metadata Policy

createAnonAuth({ sessionMetadata }) controls whether session IP addresses and user-agent strings are persisted:

| Field | Policy | Stored value | |-------|--------|--------------| | ipAddress | store | Raw IP address. This is the backwards-compatible default. | | ipAddress | omit | No IP address is stored. Recommended for maximum anonymity. | | ipAddress | hash | Deterministic hmac-sha256:<hex> using sessionSecret; pseudonymous and still correlatable within the deployment. | | ipAddress | truncate | Coarse network only: IPv4 /24 or IPv6 /48. Invalid IPs are omitted. | | userAgent | store | Raw user-agent string. This is the backwards-compatible default. | | userAgent | omit | No user-agent string is stored. Recommended for maximum anonymity. | | userAgent | hash | Deterministic hmac-sha256:<hex> using sessionSecret; pseudonymous and still correlatable within the deployment. |

Use omit for both fields when operational session metadata is not required. Use hash only when you need same-source correlation without raw values. Use IP truncate when coarse abuse/debugging context is acceptable but raw addresses are not.

Session IP and User Agent

Session records include optional ipAddress and userAgent fields. These are standard Express operational metadata used for session security (detecting session hijacking, abuse patterns). They are:

  • Ephemeral - cleaned up when sessions expire (via cleanup scheduler or expiry)
  • Not exposed - never returned in any API response
  • Not logged - not included in any log call
  • Not linked to identity - there is no identity to link to; the user record contains only a random UUID, random codename, and NEAR account
  • Configurable - raw storage is retained only for backwards compatibility; set sessionMetadata to omit, hash, or IP-truncate values before persistence

With full database access under the default store policy, an attacker would see: a random UUID, a random codename, a NEAR account, and a list of session IPs/user agents. But there is no name, email, phone, or external identifier to connect any of it to a real person. The IP tells you a session came from an ISP - not who the person is. For the strictest anonymous-track posture, configure sessionMetadata: { ipAddress: 'omit', userAgent: 'omit' } so those operational fields are not stored at all.

Threat Model Summary

| Threat | Mitigated | How | |--------|-----------|-----| | Server operator identifies user | Yes | No PII in anonymous user record | | Database breach reveals identity | Yes | Only random UUIDs, codenames, and public keys | | Log exfiltration reveals identity | Yes | Logging silent by default; no PII in passkey log calls | | Device fingerprinting via WebAuthn | Yes | Attestation set to 'none' | | Cross-track deanonymization (OAuth -> passkey) | Yes | Separate DB tables and type system | | Cross-track deanonymization (enterprise -> passkey) | Yes | enterprise_users is opt-in and separate from anon_users; no enterprise identifiers in anonymous analytics or route responses | | NEAR account -> real identity | Yes | Derived from random UUID; unpredictable with derivationSalt | | Recovery backup contents leaked | Yes | AES-256-GCM encrypted with user password | | Rate limiter IP persistence | Yes | In-memory only, never written to disk |

License

MIT

Contributing

Contributions welcome! Please read our contributing guidelines first.