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

local-first-auth-import-export

v1.0.0

Published

Import and export a Local First Auth profile (public + private keypair) as a portable JSON file. Lets users bring an existing identity into a mini app, or back one up, instead of creating a new profile.

Downloads

178

Readme

Local First Auth — Import / Export

Let users bring an identity they already have into your mini app, or back one up — instead of creating a new profile.

This is the companion to local-first-auth. That package creates a profile (mints a fresh Ed25519 keypair + did:key). This one moves one:

  • Export — download the user's profile (DID + public/private keypair, name, socials, avatar) as a portable .json file.
  • Import — restore that file on another device or in another app. The DID is preserved — no new keypair is generated.

Both packages read and write the same LocalStorage keys, so they're fully interoperable: a profile created by local-first-auth's <Onboarding /> can be exported here, and a profile imported here works immediately with local-first-auth's hooks, window.localFirstAuth, and JWT flow.

Installation

npm install local-first-auth-import-export

Quick Start

React

import { ImportExport } from 'local-first-auth-import-export/react'

function BackupPage() {
  return (
    <ImportExport
      onComplete={(profile) => console.log('Imported:', profile.did)}
      customStyles={{ primaryColor: '#403B51' }}
    />
  )
}

Or use the two halves independently:

import { ImportProfile, ExportProfile } from 'local-first-auth-import-export/react'

<ExportProfile onExport={(p) => console.log('Backed up', p.did)} />
<ImportProfile onComplete={(p) => console.log('Restored', p.did)} />

Vanilla JS

import {
  exportProfile,
  downloadProfile,
  importProfile,
  importProfileFromFile,
} from 'local-first-auth-import-export'

// Export — triggers a .json download
downloadProfile()

// Import — from a file input
await importProfileFromFile(fileInput.files[0])

// Import — from a JSON string, or even a bare private key
const profile = await importProfile(jsonString)
console.log(profile.did) // same DID as before

⚠️ Security

The exported file contains the user's private key in plain text. Anyone who has it can act as that user — it is their identity.

  • Never upload it to a server, log it, or send it over the network.
  • Tell users to store it somewhere safe (a password manager, an encrypted drive).
  • <ExportProfile /> shows this warning and keeps the key hidden behind a "Reveal" click by default.

There is no password-encrypted export in v1.

The export file format

The file is self-identifying and versioned, so any consumer can cheaply confirm it's a valid Local First Auth export before trusting it.

{
  "type": "local-first-auth:export",
  "version": 1,
  "did": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
  "publicKey": "3dxk...",
  "privateKey": "hR8f...",
  "name": "Alice Anderson",
  "socials": [{ "platform": "INSTAGRAM", "handle": "alice" }],
  "avatar": null,
  "exportedAt": "2026-07-12T10:30:00.000Z"
}

| Field | Type | Required | Description | | --- | --- | --- | --- | | type | string | Yes | Always "local-first-auth:export". The magic string identifying the format. | | version | number | Yes | Schema version. Currently 1. | | did | string | Yes | The user's DID (did:key:z...). | | publicKey | string | Yes | base64, 32-byte Ed25519 public key. | | privateKey | string | Yes | base64, 64-byte Ed25519 secret key. Secret. | | name | string | No | Display name. | | socials | array | No | { platform, handle } entries. | | avatar | string | null | No | Avatar as a data: URI. | | exportedAt | string | No | ISO 8601 export timestamp. | | scope | string | No | WHATWG origin (e.g. "https://example.com") when the identity is origin-scoped: privateKey is the per-origin derived key for exactly that origin. Absent = root identity. See Per-origin DIDs. |

Validation

A file is valid only if all of these hold — validateExportedProfile() checks them in order:

  1. It parses as a JSON object.
  2. type is exactly "local-first-auth:export".
  3. version is a supported version (1).
  4. privateKey is base64 that decodes to exactly 64 bytes.
  5. Consistency: did and publicKey are re-derivable from privateKey. A file whose DID doesn't match its key is corrupt or tampered with, and is rejected. (This holds for scoped files too — their did/publicKey describe the derived key.)
  6. socials, if present, is an array of { platform, handle }; scope, if present, is a non-empty string. Whether scope matches the current origin is checked at import time, not here — the validator is environment-free.
import { validateExportedProfile } from 'local-first-auth-import-export'

const result = validateExportedProfile(untrustedJson)
if (!result.valid) {
  console.error(result.errors) // e.g. ["Unsupported export version: 99"]
}

The private key is always the source of truth: on import, the DID and public key are re-derived from it rather than read from the file.

How import works

An Ed25519 secret key is 64 bytes: a 32-byte seed followed by the 32-byte public key. The keypair is regenerated from the seed, so the private key alone is enough to rebuild the whole identity — and a tampered trailing 32 bytes can never split the DID from the actual signing key:

keypair   = Ed25519.generateKeyPairFromSeed(secretKey[0..32])
did       = "did:key:z" + base58btc(0xed 0x01 ‖ keypair.publicKey)

That's why importing never mints a new keypair — it recovers the existing one. This is the same did:key derivation local-first-auth uses, so the DIDs match exactly.

Because of that, importProfile() also accepts a bare base64 private key with no envelope:

await importProfile('hR8f...') // name defaults to 'anonymous'

A bare key is always treated as a root identity (there's no envelope to carry a scope).

Per-origin (pairwise) DIDs and the scope field

The Local First Auth spec scopes identity per website: the stored keypair is a root key that never signs mini-app payloads directly. For each origin, implementations derive a distinct keypair:

originSeed = HKDF-SHA256(
  ikm  = rootSeed,                                 // first 32 bytes of the 64-byte secret key
  salt = UTF-8("local-first-auth:origin-key:v1"),  // pinned by the spec
  info = UTF-8(origin),                            // WHATWG origin, e.g. "https://example.com"
  length = 32
)

so every site sees its own stable DID (iss in the JWTs), and no two sites can correlate the user. deriveOriginKeys(rootPrivateKey, origin) implements this; the injected window.localFirstAuth uses it automatically. The same rule is implemented by local-first-auth, so a profile behaves identically no matter which package injected the API. See docs/scope-support.md for the full spec and test vectors.

The scope field ties the two identity shapes together:

  • scope absent (root identity) — signing derives the per-origin key for window.location.origin on every call. This is what profile backups are.
  • scope present (origin-scoped identity) — the file's key is the already-derived per-origin key for that one origin, e.g. exported by a native Local First Auth app (Antler) so the user can hand a website its identity without ever exposing the root key. Importing it:
    • succeeds only on exactly that origin — anywhere else importProfile() throws (This profile is scoped to <scope> and can't be imported on <origin>.);
    • persists scope, so signing uses the key directly (deriving from it again would produce a DID nobody has ever seen) and refuses to sign on any other origin;
    • re-exports with the scope field preserved — a scoped identity never masquerades as a root backup.

One-time behavior change (v3): identities imported before per-origin derivation signed with the raw stored key, so sites saw the root DID. After it, a root identity presents its derived per-origin DID — sites see the user's DID change once. Mini apps needing continuity must link old and new DIDs at the application level; the spec defines no automatic migration.

Overwrite protection

Importing over an existing, different identity would permanently destroy the user's current private key. So it throws unless you opt in:

await importProfile(json)                      // throws if a different profile exists
await importProfile(json, { overwrite: true }) // explicitly replaces it

Re-importing the same DID is always allowed (it's idempotent). <ImportProfile /> surfaces this as a warning plus a confirmation checkbox.

API Reference

Core

// Export
exportProfile(): ExportedProfile | null
exportProfileToJSON(pretty?: boolean): string | null
downloadProfile(filename?: string): ExportedProfile | null
defaultExportFilename(did: string): string

// Import
importProfile(input: string | ExportedProfile, opts?: { overwrite?: boolean }): Promise<Profile>
importProfileFromFile(file: File, opts?: { overwrite?: boolean }): Promise<Profile>
parseExportedProfile(input: string | ExportedProfile): ExportedProfile  // throws
validateExportedProfile(input: unknown): ValidationResult               // never throws

// Keys
deriveKeysFromPrivateKey(privateKey: string): ProfileKeys
validatePrivateKey(privateKey: unknown): { valid: boolean; error?: string }
createDidFromPublicKey(publicKey: Uint8Array): string
decodePublicKey(publicKey: string): Uint8Array

// JWT (Local First Auth spec)
createJWT(payload, privateKey): Promise<string>
decodeJWT(jwt): { header, payload, signature }
verifyJWT(jwt, publicKey: Uint8Array): boolean

// Storage (same keys as local-first-auth)
getCurrentProfile(), hasProfile(), clearProfile(), getPrivateKey(), STORAGE_KEYS

// Window API
injectLocalFirstAuthAPI(), removeLocalFirstAuthAPI(), hasLocalFirstAuthAPI()

React

<ImportProfile customStyles? onComplete?(profile) onBack? />
<ExportProfile customStyles? onExport?(exported) emptyState? />
<ImportExport  customStyles? onComplete? onExport? defaultTab? skipImport? skipExport? />

const { profile, refresh } = useProfile()

All three components accept the same customStyles object as local-first-auth, so they theme identically:

<ImportExport customStyles={{
  primaryColor: '#403B51',
  backgroundColor: '#ffffff',
  borderRadius: '12px',
  fontFamily: 'Inter, sans-serif',
}} />

Storage

Identical to local-first-auth — this is what makes them interoperable:

{
  'local-first-auth:profile':    '{"did":"did:key:z6Mk...","name":"Alice",...}',
  'local-first-auth:privateKey': 'base64-encoded-64-byte-key'
}

For origin-scoped identities, the profile JSON carries an extra scope field (read by both packages; written only by this package's import path — local-first-auth's createProfile() never sets it).

After a successful import, window.localFirstAuth is injected, so getProfileDetails() immediately returns JWTs signed with the per-origin key (or the stored key directly, for a scoped identity).

Development

npm run build       # dual CJS+ESM build with type declarations
npm run type-check  # tsc --noEmit
npm test            # build, then round-trip + component render tests
npm run dev:example # example app at http://localhost:5174

Tests

  • test/roundtrip.mjs — verifies against the built dist: DID derivation matches local-first-auth's algorithm exactly, export → clear → import preserves the DID, JWTs sign with the per-origin derived key (the spec's HKDF test vectors are reproduced byte-for-byte), origin-scoped files import only on their own origin and re-export with scope, the validator rejects tampered files, and the overwrite guard holds.
  • test/render.mjs — smoke-renders the React components (including that the private key is hidden by default).

Example app

/example is a Vite + React harness with four demos: Round Trip (runs the core assertions in-browser), Export, Import, and Combined UI (themed <ImportExport />). Run npm run build first — the example resolves the package through its built dist.

License

MIT