@vitalpoint/near-phantom-auth
v0.8.0
Published
Anonymous passkey authentication with NEAR MPC accounts and decentralized recovery
Maintainers
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 (
testnetormainnet). 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
FullAccesskey. 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.
- Wallet recovery — link a NEAR wallet on-chain via a
- 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
MPCAccountManagerdirectly 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,
HttpOnlycookies, 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
FullAccessaccess key, not stored in our DB) - Password + IPFS backup (encrypted, you hold the keys)
- Link a NEAR wallet (on-chain
- 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
v1suffix 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:
- 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. - Spreads the validated paired tuples into
expectedOriginandexpectedRPIDon everyverifyRegistrationResponse/verifyAuthenticationResponsecall, preserving pairing by tuple order. - 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
- passkeys.dev — Related Origin Requests
- web.dev — WebAuthn Related Origin Requests
- W3C WebAuthn Level 3 §5.10.3 — Related Origin Requests algorithm
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:
POST /register/finish— after passkey verify +mpcManager.createAccountdb.createUser+db.createPasskey, BEFOREsessionManager.createSession. Fires INSIDE thedb.transaction()wrapper — a hook throw rolls back the DB rows. (src/server/router.ts)
POST /login/finish— after passkey verify +db.getUserById, BEFOREsessionManager.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 bypasskeyManager.finishAuthenticationmust persist for replay protection). (src/server/router.ts)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)
- IPFS recovery setup.
NO transaction wrapper on any OAuth branch. (
- Existing user, same provider — fires after
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..06in.planning/REQUIREMENTS.md - Type definitions:
src/types/index.ts(search forAfterAuthSuccessCtx) - 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 14hooks.afterAuthSuccessthat returnedcontinue: true, BEFOREsessionManager.createSession. It fires only whensealingKeyHexwas supplied in the request body. No PRF means no fresh sealing key, so the hook is silently skipped and nobackfillfield appears on the response.
Sequential ordering when both Phase 14 and Phase 15 hooks are configured:
hooks.afterAuthSuccessruns first.- If it returns
{ continue: false, ... }, the response short-circuits and the backfill hook is not invoked. - If it returns
{ continue: true }(or is absent), andsealingKeyHexis present, andhooks.backfillKeyBundleexists, the backfill hook fires. - The result is echoed on the login response under an additive
backfillkey, alongside the existingsuccess,codename, andpasskey?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..04in.planning/REQUIREMENTS.md - Type definitions:
src/types/index.ts(search forBackfillKeyBundleCtx) - 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.afterAuthSuccessruns 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.backfillKeyBundlegives your codesealingKeyHexand 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-authThe 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
registerandloginfunctions 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 setRecovery 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 fundingDerivation 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
derivationSaltis REQUIRED at the type level — TypeScript rejects anMPCAccountManagerConfigliteral that omits it. Use a per-tenant secret salt to prevent cross-tenant account ID collision.treasuryPrivateKeyis never logged — the manager replaces the raw string with aKeyPairobject on construction. The default-silent pino logger is wired with redact paths (config.treasuryPrivateKey,*.treasuryPrivateKey); even an accidentallog.info({ config }, '...')emits[Redacted]instead of the secret.- Transactions are signed in-process — no
near-clishell-out, noprocess.execinjection vector. verifyRecoveryWalletreturns true ONLY forFullAccesskeys — FunctionCall-scoped keys (which cannot sign arbitrary transactions) cannot satisfy recovery verification.- Dist bundle is leak-audited — the published
dist/server/index.jsis checked at build time to confirm zeroed25519:<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 MPCAccountManagercreateAccount(userId: string): Promise<CreateAccountResult>verifyRecoveryWallet(nearAccountId: string, recoveryWalletPublicKey: string): Promise<boolean>interface MPCAccountManagerConfig(withderivationSalt: stringREQUIRED)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:
- New user registers with passkey
- System derives deterministic implicit account ID (64-char hex)
- Treasury sends 0.01 NEAR to activate the account
- 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
derivationSaltto prevent NEAR account ID prediction - [ ] Set
sessionSecretto a cryptographically random value (32+ bytes) - [ ] Enable
csrfwith a separate secret if your frontend is on a different origin - [ ] Configure
rateLimitingthresholds appropriate for your traffic - [ ] Set
sessionMetadatatoomitorhashIP/user-agent data if your threat model treats operational metadata as identifying - [ ] Provide a
loggerinstance with appropriate redaction for your environment - [ ] Run
createCleanupSchedulerto 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
sessionMetadatato 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.
