Skip to main content
Technology & EngineeringLocal First606 lines

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.

Quick Summary24 lines
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 lines
Paste into your CLAUDE.md or agent config

Auth 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

PitfallWhy It HappensFix
Storing tokens in localStorageSeems simple, but XSS can steal themUse encrypted IndexedDB
No offline grace periodUsers get locked out immediatelyCache session with configurable TTL
Hardcoded encryption keysDeveloper shortcutAlways derive keys from user credentials
No key rotation on device revocationRevoked device can still decrypt sync dataRotate sync encryption key on revocation
Permissions never expire locallyRevoked access persists offlineAdd TTL to cached permissions
No recovery pathUser forgets password, data is lostProvide recovery keys at setup
Syncing unencrypted dataData exposed on the wire and at rest on serverEncrypt before sync, decrypt after receive
Same key for all purposesCompromising one key compromises everythingUse a key hierarchy (master, DEK, SEK)

Install this skill directly: skilldb add local-first-skills

Get CLI access →

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.

Local First454L

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.

Local First433L

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 First556L

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.

Local First285L

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.

Local First572L

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.

Local First471L