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
Maintainers
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
.jsonfile. - 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-exportQuick 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:
- It parses as a JSON object.
typeis exactly"local-first-auth:export".versionis a supported version (1).privateKeyis base64 that decodes to exactly 64 bytes.- Consistency:
didandpublicKeyare re-derivable fromprivateKey. A file whose DID doesn't match its key is corrupt or tampered with, and is rejected. (This holds for scoped files too — theirdid/publicKeydescribe the derived key.) socials, if present, is an array of{ platform, handle };scope, if present, is a non-empty string. Whetherscopematches 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:
scopeabsent (root identity) — signing derives the per-origin key forwindow.location.originon every call. This is what profile backups are.scopepresent (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
scopefield preserved — a scoped identity never masquerades as a root backup.
- succeeds only on exactly that origin — anywhere else
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 itRe-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:5174Tests
test/roundtrip.mjs— verifies against the builtdist: DID derivation matcheslocal-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 withscope, 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
