Skip to content

Repository files navigation

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

About

Be able to import and export profiles using the Local First Auth spec

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages