local-first-auth
Teaches authentication and authorization patterns for local-first applications that must work offline. Covers offline-capable auth with cached tokens, permission sync and local enforcement, encrypted local storage for sensitive data, key management with device-bound keys, device authorization and revocation, multi-device identity linking, end-to-end encryption for synced data, and secure patterns for handling auth in disconnected environments.
Authentication and authorization patterns that work when the network is unavailable.
## Key Points
- How do you verify identity without a server?
- How do you enforce permissions that changed while the device was offline?
- How do you protect local data from physical access to the device?
## Quick Example
```
High security (banking): 15 minutes
Medium security (work apps): 7 days
Low security (notes app): 30 days
Personal-only (journal): Never expire locally
```
```
Online: Access token --> Use until expiry --> Refresh --> New access token
Offline: Access token --> Use until expiry --> No server available
--> Fall back to cached session
```skilldb get local-first-skills/local-first-authFull skill: 606 linesAuth in Local-First Apps
Authentication and authorization patterns that work when the network is unavailable.
The Fundamental Challenge
Traditional auth checks credentials against a server on every request. Local-first apps must work offline, which means auth decisions happen locally, with stale or cached credentials, and without a server to validate against.
Cloud auth:
User action --> Server validates token --> Allow/deny
Local-first auth:
User action --> Local credential check --> Allow/deny
| (when online)
Sync auth state with server
This creates tensions:
- How do you verify identity without a server?
- How do you enforce permissions that changed while the device was offline?
- How do you protect local data from physical access to the device?
Offline-Capable Authentication
Cached Session Pattern
The most practical approach: authenticate online, cache the session locally, and use the cached session while offline.
interface CachedSession {
userId: string;
accessToken: string;
refreshToken: string;
expiresAt: number; // Token expiration
offlineGracePeriod: number; // How long to allow offline use
cachedAt: number; // When the session was last validated online
permissions: Permission[]; // Cached permissions
}
class OfflineAuth {
private session: CachedSession | null = null;
async authenticate(email: string, password: string): Promise<CachedSession> {
// Must be online for initial auth
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new AuthError('Invalid credentials');
const session = await response.json();
session.cachedAt = Date.now();
// Persist session to encrypted local storage
await this.persistSession(session);
this.session = session;
return session;
}
isAuthenticated(): boolean {
if (!this.session) return false;
const now = Date.now();
const offlineDeadline = this.session.cachedAt + this.session.offlineGracePeriod;
// Allow offline use within the grace period
if (now < offlineDeadline) return true;
// Past grace period: require re-authentication
return false;
}
private async persistSession(session: CachedSession): Promise<void> {
const encrypted = await encrypt(JSON.stringify(session), this.deviceKey);
await localStore.set('session', encrypted);
}
}
Offline Grace Period Strategy
Choose a grace period based on your security requirements:
High security (banking): 15 minutes
Medium security (work apps): 7 days
Low security (notes app): 30 days
Personal-only (journal): Never expire locally
PIN/Biometric for Offline Re-auth
For longer offline periods, allow local re-authentication with a PIN or biometric.
class LocalReauth {
async setupPin(pin: string, session: CachedSession): Promise<void> {
// Derive a key from the PIN
const pinKey = await deriveKey(pin, this.deviceSalt);
// Encrypt the session with the PIN-derived key
const encrypted = await encrypt(JSON.stringify(session), pinKey);
await localStore.set('pin-protected-session', encrypted);
}
async reauthWithPin(pin: string): Promise<CachedSession> {
const encrypted = await localStore.get('pin-protected-session');
if (!encrypted) throw new AuthError('No cached session');
const pinKey = await deriveKey(pin, this.deviceSalt);
try {
const decrypted = await decrypt(encrypted, pinKey);
return JSON.parse(decrypted);
} catch {
throw new AuthError('Invalid PIN');
}
}
}
Token Caching and Renewal
Token Lifecycle Offline
Online: Access token --> Use until expiry --> Refresh --> New access token
Offline: Access token --> Use until expiry --> No server available
--> Fall back to cached session
Proactive Token Refresh
Refresh tokens before they expire, while online. Do not wait for expiry.
class TokenManager {
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
startAutoRefresh(session: CachedSession) {
const refreshIn = (session.expiresAt - Date.now()) * 0.8; // At 80% of lifetime
this.refreshTimer = setTimeout(async () => {
if (navigator.onLine) {
try {
const newSession = await this.refresh(session.refreshToken);
await this.persistSession(newSession);
this.startAutoRefresh(newSession);
} catch {
// Refresh failed — session will expire, user re-authenticates when online
console.warn('Token refresh failed, will retry when online');
this.retryOnReconnect(session);
}
} else {
// Offline — retry when back online
this.retryOnReconnect(session);
}
}, Math.max(refreshIn, 0));
}
private retryOnReconnect(session: CachedSession) {
window.addEventListener('online', async () => {
try {
const newSession = await this.refresh(session.refreshToken);
await this.persistSession(newSession);
this.startAutoRefresh(newSession);
} catch {
// Refresh token also expired — force re-authentication
this.forceReauth();
}
}, { once: true });
}
private async refresh(refreshToken: string): Promise<CachedSession> {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) throw new AuthError('Refresh failed');
const session = await response.json();
session.cachedAt = Date.now();
return session;
}
}
Token Storage Security
Never store tokens in localStorage (accessible to XSS attacks). Use encrypted IndexedDB or platform-specific secure storage.
// Anti-pattern: tokens in localStorage
localStorage.setItem('token', accessToken); // XSS can read this
// Better: encrypted IndexedDB
async function storeToken(token: string, encryptionKey: CryptoKey): Promise<void> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
encryptionKey,
new TextEncoder().encode(token)
);
await idb.put('auth', {
key: 'token',
iv: Array.from(iv),
data: Array.from(new Uint8Array(encrypted)),
});
}
Permission Sync
Caching Permissions Locally
Permissions must be available offline. Cache them alongside the session.
interface Permission {
resource: string; // e.g., 'workspace:123'
action: string; // e.g., 'read', 'write', 'admin'
grantedAt: number;
expiresAt?: number;
}
class PermissionCache {
private permissions: Permission[] = [];
async syncPermissions(): Promise<void> {
if (!navigator.onLine) return;
const response = await fetch('/api/auth/permissions');
const serverPermissions: Permission[] = await response.json();
this.permissions = serverPermissions;
await localStore.set('permissions', JSON.stringify(serverPermissions));
}
async loadCached(): Promise<void> {
const cached = await localStore.get('permissions');
if (cached) {
this.permissions = JSON.parse(cached);
}
}
can(action: string, resource: string): boolean {
const now = Date.now();
return this.permissions.some(p =>
p.action === action &&
p.resource === resource &&
(!p.expiresAt || p.expiresAt > now)
);
}
}
Handling Permission Revocation
The hard problem: a user's access was revoked on the server, but their device is offline and still has cached permissions.
// Strategy 1: Short permission TTL
// Permissions expire quickly, forcing re-sync
interface TimedPermission extends Permission {
ttl: number; // e.g., 3600 seconds
}
function isPermissionValid(perm: TimedPermission): boolean {
return Date.now() < perm.grantedAt + (perm.ttl * 1000);
}
// Strategy 2: Permission version counter
// Every permission change increments a version; client checks on sync
async function checkPermissionVersion(currentVersion: number): Promise<boolean> {
const response = await fetch('/api/auth/permission-version');
const { version } = await response.json();
return version === currentVersion; // False = permissions changed, re-sync
}
// Strategy 3: Pessimistic offline mode
// When offline, restrict to read-only; apply writes after permission re-check
class PessimisticPermissions {
can(action: string, resource: string): boolean {
if (!navigator.onLine && action !== 'read') {
// Queue write operations for server validation when online
return false;
}
return this.permissionCache.can(action, resource);
}
}
Permission Conflict Resolution
When the client and server disagree about permissions, the server always wins.
async function reconcilePermissions(engine: SyncEngine): Promise<void> {
const serverPerms = await fetchServerPermissions();
const localOps = await engine.getPendingOps();
// Check each pending op against current server permissions
const unauthorized = localOps.filter(op => {
const action = op.type === 'delete' ? 'delete' : 'write';
return !serverPerms.some(p => p.resource === op.collection && p.action === action);
});
// Roll back unauthorized ops
for (const op of unauthorized) {
await engine.rollbackOp(op);
engine.notifyUser(`Your edit to ${op.collection}/${op.documentId} was reverted: insufficient permissions`);
}
}
Encrypted Local Storage
Why Encrypt Locally
If someone has physical access to the device (stolen laptop, shared computer), they should not be able to read app data by inspecting IndexedDB directly.
Encryption Architecture
User password
|
v
PBKDF2 / Argon2id (key derivation)
|
v
Master Key (never stored directly)
|
|---> Encrypt/Decrypt data in IndexedDB
|
+---> Encrypt/Decrypt session tokens
Implementation with Web Crypto API
class EncryptedStore {
private masterKey: CryptoKey | null = null;
async deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveKey']
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
async encrypt(data: string): Promise<{ iv: Uint8Array; ciphertext: ArrayBuffer }> {
if (!this.masterKey) throw new Error('Store is locked');
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
this.masterKey,
new TextEncoder().encode(data)
);
return { iv, ciphertext };
}
async decrypt(iv: Uint8Array, ciphertext: ArrayBuffer): Promise<string> {
if (!this.masterKey) throw new Error('Store is locked');
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
this.masterKey,
ciphertext
);
return new TextDecoder().decode(plaintext);
}
}
Anti-Pattern: Encrypting with a Hardcoded Key
// WRONG: the key is in the source code, anyone can read it
const key = 'my-secret-encryption-key-1234567';
const encrypted = aesEncrypt(data, key);
// RIGHT: derive the key from the user's password
const key = await deriveKey(userPassword, storedSalt);
const encrypted = await encrypt(data, key);
Key Management
Key Hierarchy
User Password
|
v
Key Derivation Function (PBKDF2 / Argon2id)
|
v
Master Key (derived, never stored)
|
|---> Data Encryption Key (DEK) -- encrypts user data
| (encrypted by Master Key, stored locally)
|
+---> Sync Encryption Key (SEK) -- encrypts data in transit
(encrypted by Master Key, stored locally)
Why a Key Hierarchy
If the user changes their password, you only re-encrypt the DEK and SEK with the new master key. You do not re-encrypt all data.
async function changePassword(oldPassword: string, newPassword: string): Promise<void> {
// 1. Derive old master key
const oldMasterKey = await deriveKey(oldPassword, salt);
// 2. Decrypt the DEK with old master key
const dekEncrypted = await localStore.get('dek');
const dek = await decrypt(dekEncrypted, oldMasterKey);
// 3. Derive new master key
const newSalt = crypto.getRandomValues(new Uint8Array(16));
const newMasterKey = await deriveKey(newPassword, newSalt);
// 4. Re-encrypt DEK with new master key
const dekReencrypted = await encrypt(dek, newMasterKey);
await localStore.set('dek', dekReencrypted);
await localStore.set('salt', newSalt);
// Data stays encrypted with the same DEK — no re-encryption needed
}
Key Backup and Recovery
If the user loses their password and there is no server-side recovery, their data is gone. Provide options:
// Recovery key: generate a random recovery key and show it once
async function generateRecoveryKey(masterKey: CryptoKey): Promise<string> {
const recoveryKey = crypto.getRandomValues(new Uint8Array(32));
// Encrypt the DEK with the recovery key
const rekCryptoKey = await crypto.subtle.importKey(
'raw', recoveryKey, { name: 'AES-GCM' }, false, ['encrypt']
);
const dekEncrypted = await localStore.get('dek');
const dekForRecovery = await encrypt(dekEncrypted, rekCryptoKey);
await localStore.set('recovery-dek', dekForRecovery);
// Return human-readable recovery key
return Array.from(recoveryKey)
.map(b => b.toString(16).padStart(2, '0'))
.join('')
.match(/.{8}/g)!
.join('-');
// e.g., "a1b2c3d4-e5f6a7b8-..."
}
Device Authorization
Registering a New Device
When a user sets up a new device, they need to transfer their encryption keys securely.
// Pattern: QR code key transfer
// Existing device generates a temporary transfer code
async function generateTransferCode(session: CachedSession): Promise<string> {
// 1. Create a short-lived transfer token on the server
const response = await fetch('/api/auth/device-transfer', {
method: 'POST',
headers: { Authorization: `Bearer ${session.accessToken}` },
});
const { transferToken, expiresIn } = await response.json();
// 2. Encrypt DEK with transfer token
const dek = await getDEK();
const transferKey = await deriveKey(transferToken, new Uint8Array(16));
const encryptedDek = await encrypt(dek, transferKey);
// 3. Upload encrypted DEK to server (temporary, expires in 5 minutes)
await fetch('/api/auth/device-transfer/key', {
method: 'POST',
headers: { Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify({ encryptedDek, transferToken }),
});
return transferToken; // Display as QR code
}
// New device scans QR code
async function acceptTransfer(transferToken: string, password: string): Promise<void> {
// 1. Download encrypted DEK from server
const response = await fetch(`/api/auth/device-transfer/key?token=${transferToken}`);
const { encryptedDek } = await response.json();
// 2. Decrypt DEK with transfer token
const transferKey = await deriveKey(transferToken, new Uint8Array(16));
const dek = await decrypt(encryptedDek, transferKey);
// 3. Re-encrypt DEK with this device's master key
const salt = crypto.getRandomValues(new Uint8Array(16));
const masterKey = await deriveKey(password, salt);
const localDek = await encrypt(dek, masterKey);
await localStore.set('dek', localDek);
await localStore.set('salt', salt);
}
Device Revocation
When a device is lost or stolen, revoke its access.
async function revokeDevice(deviceId: string, session: CachedSession): Promise<void> {
// 1. Tell the server to revoke the device
await fetch(`/api/auth/devices/${deviceId}/revoke`, {
method: 'POST',
headers: { Authorization: `Bearer ${session.accessToken}` },
});
// 2. Rotate the sync encryption key
// The revoked device has the old SEK and can no longer decrypt new sync data
const newSEK = await generateNewSEK();
await distributeKeyToActiveDevices(newSEK, session);
// 3. The revoked device will fail to sync and get a 401
// Its local data remains encrypted with its local DEK
// but it cannot receive new data or push changes
}
Device List Management
interface Device {
id: string;
name: string;
platform: string; // 'web', 'ios', 'android', 'desktop'
lastSeen: number;
authorized: boolean;
}
// Show users their active devices
async function listDevices(session: CachedSession): Promise<Device[]> {
const response = await fetch('/api/auth/devices', {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
return response.json();
}
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Storing tokens in localStorage | Seems simple, but XSS can steal them | Use encrypted IndexedDB |
| No offline grace period | Users get locked out immediately | Cache session with configurable TTL |
| Hardcoded encryption keys | Developer shortcut | Always derive keys from user credentials |
| No key rotation on device revocation | Revoked device can still decrypt sync data | Rotate sync encryption key on revocation |
| Permissions never expire locally | Revoked access persists offline | Add TTL to cached permissions |
| No recovery path | User forgets password, data is lost | Provide recovery keys at setup |
| Syncing unencrypted data | Data exposed on the wire and at rest on server | Encrypt before sync, decrypt after receive |
| Same key for all purposes | Compromising one key compromises everything | Use a key hierarchy (master, DEK, SEK) |
Install this skill directly: skilldb add local-first-skills
Related Skills
crdt-fundamentals
Teaches Conflict-free Replicated Data Types (CRDTs), the mathematical foundation for local-first sync. Covers how CRDTs guarantee eventual consistency without coordination, the difference between state-based and operation-based CRDTs, and practical implementations of G-Counter, PN-Counter, LWW-Register, OR-Set, G-Set, and RGA (Replicated Growable Array). Includes causal ordering, vector clocks, and guidance on choosing the right CRDT for your data model.
electric-sql
Teaches ElectricSQL, a Postgres-backed local-first sync framework. Covers the Electric architecture where Postgres is the source of truth and data syncs to local SQLite databases on client devices via shape-based partial replication. Includes shape definitions, live queries, offline-first patterns, conflict resolution with rich CRDTs, integration with React and Expo (React Native), deployment patterns, and migration strategies.
indexeddb-patterns
Teaches IndexedDB patterns for local-first web applications, using Dexie.js as the primary wrapper library. Covers schema design and versioning, creating indexes for efficient queries, transaction patterns, performance optimization (bulk operations, pagination, lazy loading), migration strategies for schema evolution, storage quota management, data export and import, and integration patterns with sync engines and reactive frameworks.
local-first-fundamentals
Teaches the local-first software paradigm where applications store data on the user's device, work fully offline, and sync to peers or servers when connectivity is available. Covers the spectrum from cloud-first to offline-first to local-first, core benefits (instant UX, offline capability, data ownership, privacy), key challenges (conflict resolution, sync complexity, storage limits), architectural patterns, and decision frameworks for when local-first is the right choice.
sync-engine-architecture
Teaches how to design and build a sync engine for local-first applications. Covers the operation log as the foundation, conflict resolution strategies (last-write-wins, operational transform, CRDTs), server reconciliation patterns, partial sync for large datasets, bandwidth optimization techniques, version vectors and causal consistency, clock synchronization, and practical implementation patterns with code examples.
yjs-sync
Teaches building local-first collaborative applications with Yjs, the most widely adopted CRDT library for JavaScript. Covers the Y.Doc document model, shared types (Y.Map, Y.Array, Y.Text, Y.XmlFragment), the awareness protocol for presence and cursors, persistence and sync providers (WebSocket, WebRTC, IndexedDB), integrating with editors like ProseMirror/TipTap/CodeMirror/Monaco, undo/redo management, and performance optimization patterns.