Evolu now has local auth

Until now, every app built on Evolu 8 had to create the user's account itself, store it somewhere safe, and find it again at the next start. Our own examples didn't: they used testAppOwner, whose secret is public. For anyone who wanted to build a real app with Evolu 8, that was the biggest blocker, and the Evolu 8 post listed it, as passkey-backed local authentication, among what was missing before we could recommend Evolu local-first.

createEvoluApp removes it. It creates the app's accounts, keeps them on the device, unlocks them with a passkey, the iOS Keychain or Android Keystore, or without a prompt, and opens the one used last. It's in @evolu/common 8.28.0, with support in @evolu/web 3.8.0, @evolu/react 11.3.0, @evolu/react-native 16.3.0, @evolu/vue 2.2.0, and @evolu/svelte 3.1.0. Automatic migration from Evolu 7, also on that list, comes next.

In a local-first app, auth means something other than signing in to a server. No server signs the user in to their data, and there's no password. A relay's host can require a sign-in to sync through it, but that only decides who may use the relay, which can't read the user's data. The user's account, which Evolu calls an AppOwner, is a secret on the device: it identifies the user's data, encrypts it, and lets the user sync it to their other devices or restore it from a backup. Local auth decides what unlocks that secret on the device: a passkey, the platform's secure storage, or nothing at all.

In Evolu 7, an app started and an account was there, because Evolu created it inside the local database. Evolu 8 gave each account a local database of its own, whose name and encryption key derive from the account, so an app can have several accounts on one device. But a database encrypted with the account can't also store it, so the account has to exist before its database opens. This post explains how createEvoluApp creates and keeps it. The full reasoning, with every alternative we rejected, is in the ADR.

What unlocks the accounts

createEvoluApp keeps the app's accounts in one more database, the device database. It belongs to the device, not to any account: it holds the secret of every account the app can open on this device, and those secrets never sync. The app can keep its own data that belongs to the device rather than to an account, such as UI settings, there too, as the FAQ shows. Each account keeps its own database, encrypted with its own key. The device database's key decides how safe all of them are on the device, and the auth option decides whether malware can read that key. It has two modes: { type: "DeviceSecret", secret }, a secret only the platform or the user can unlock, and { type: "InstantStart" }, a fixed key that anyone can know:

  • On React Native, loadDeviceSecret creates a secret on the first start and keeps it in the iOS Keychain or protects it with the Android Keystore. The app passes it as { type: "DeviceSecret", secret }. The app starts without asking the user for anything, and whatever can only read the app's storage can't read the accounts. It's the mode to use on mobile.
  • A browser gives an app no secure storage that works without the user, so on the web, createPasskeyAuth unlocks a DeviceSecret with a passkey, which the user confirms on every start. It needs the WebAuthn PRF extension, which not every passkey provider supports yet.
  • { type: "InstantStart" } doesn't protect the accounts from malware on the device. It uses a fixed key that Evolu provides, so the app starts without a prompt, but the key is public. Anything that can read the app's storage, such as malware, can read every account's secret, and with it the account's data on relays. It suits apps without sensitive data or significant money.
  • In Electron and Node.js, Evolu has no secure place for the key yet, so apps use instant start or keep a DeviceSecret themselves. Keeping it with Electron's safeStorage is planned.

The mode is fixed per app, like its name. An account never moves from instant start to a DeviceSecret: its secret was readable before, and whoever copied it then can still read the account's data on relays, so moving it would only look like protection. An app that may need a DeviceSecret uses it from the first release. Local auth explains which mode to choose, and the passkey playground shows the web flow.

Accounts

With createFirstAppOwner: true, the first start creates an account, so the app starts with one, and every later start opens the account opened most recently:

import {
  AppName,
  assertEqual,
  createEvoluApp,
  id,
  NonEmptyTrimmedString100,
} from "@evolu/common";
import { createEvoluDeps, createRun } from "@evolu/web";

const Schema = { todo: { id: id("Todo"), title: NonEmptyTrimmedString100 } };
const run = createRun(createEvoluDeps());

const app = await run.orThrow(
  createEvoluApp(Schema, {
    appName: AppName.orThrow("todos"),
    // Or { type: "DeviceSecret", secret }, as above.
    auth: { type: "InstantStart" },
    createFirstAppOwner: true,
  }),
);

// The first start created an account, and this tab opened it.
const [appOwner] = app.appOwners.get();
const current = app.current.get();
assertEqual(current?.appOwner.id, appOwner.id);

// Its Evolu instance works as before.
if (current) {
  const evolu = await current.evolu;
  evolu.insert("todo", { title: NonEmptyTrimmedString100.orThrow("Buy milk") });
}

An app that starts with a choice instead, such as to create an account or restore one, sets createFirstAppOwner to false.

An app can have several accounts on one device, such as a personal one and a work one. app.appOwners lists them, app.open opens one in this tab while other tabs keep theirs, app.add adds a new one or restores one from its secret, app.rename names one, and app.remove deletes one from this device. They replace Evolu 7's restoreAppOwner and resetAppOwner, which Evolu 8 removed. EvoluApp describes each of them.

A common start creates an empty account and asks, "Already use the app? Restore your account." A user who restores one from the mnemonic they backed up doesn't need the empty one, so the app adds the restored account, which opens at once, and then removes the empty one. The restored account syncs its data back from the relays in transports. Starting over with a new account takes the same order: add the new account first, then remove the old one, so a crash between the two leaves two accounts rather than none.

remove deletes this device's copy of an account. Its changes that haven't synced are lost, its data on relays stays, and adding the account again syncs that data back. A removal that a crash interrupts can leave the account listed without its local data, and removing it again finishes the job.

Apps whose accounts come from elsewhere, such as from a hardware wallet in an app like Trezor Suite, keep calling createEvolu with their own appOwner.

React, Vue, and Svelte

In React, createEvoluBinding returns EvoluAppProvider, which takes the app's promise, created once at module scope, such as appPromise from run.orThrow(createEvoluApp(…)), and provides the open account's instance, so useEvolu and the query hooks read that account's data. When the user opens another account, its children remount with the new one. While no account is open, such as after the user removed the last one, it renders withoutAppOwner instead.

useAppOwners lists the accounts, such as for an account switcher, and useEvoluApp returns the app, to open, add, or remove one. Vue has its own EvoluAppProvider, and in Svelte, evoluAppState follows the open account and its instance.

Why apps couldn't just do it themselves

Creating an account is easy. Keeping it, and creating exactly one, are the hard parts.

There's no good place to keep it. Platform storage, such as localStorage on the web or the Keychain on iOS, has no schema, can't be queried like the rest of the app's data, and works differently on each platform, and on the web, anything that can read the app's storage can also read localStorage. The device database has a schema and works the same on every platform. With a DeviceSecret, secure storage holds only its key, and whatever can only read the app's storage can't read the accounts. With instant start, it protects them no better than localStorage would.

Exactly one has to be created. On the web, an app often runs in several tabs. When the user opens two at once, both look for an account, both find none, and both create one. The user now has two accounts, and each tab writes into a different one. A check in localStorage races the same way. Writing the account under a fixed ID doesn't help either: the second tab overwrites the first tab's account while the first tab keeps using its own. Preventing that takes reading, deciding, and writing as one step that no other tab can interleave, and each query and each mutation reaches Evolu's database as a separate request. IndexedDB's transactions could do it, but IndexedDB would be one more storage, only on the web, that the browser can lose apart from the databases. A Web Lock around the three steps works with a read that skips the cache, which this release adds as loadQuery's cache: false, but every app would have to get that right, each check costs round trips to the database worker, and locks taken in callbacks can nest and deadlock.

We considered giving apps an exclusive transaction for cases like this, but every other database operation would wait while app code awaited inside it, and nested or abandoned transactions would need rules of their own. Creating one account doesn't need any of that.

The first account, without a race

createFirstAppOwner needs no new transaction API, because Evolu already has the transaction it needs. Only one database worker opens a database at a time, which a lock ensures, and its startup, which creates the database and its tables, is one SQLite transaction that commits before the worker answers any request. The startup that creates the device database also creates the first account, in that same transaction. Every tab's first read waits for that commit, so no tab can find the list empty and create another account.

Evolu creates it only together with the device database, not whenever it finds no account. A database worker starts again whenever, for example, another tab takes it over, so if Evolu created an account whenever the list was empty, a user who removed every account would see a new one appear, possibly in the middle of a session.

What it costs

Keeping accounts on the device has costs that apps should know:

  • Losing the app's storage loses the accounts, and so does losing the device secret: on the web when the user deletes the passkey, and on mobile on a new phone, also one restored from a device backup, because the Keychain and the Keystore keep the secret on one device. Browsers delete storage for several reasons: Safari, for example, deletes it after seven days of Safari use without the user interacting with the site. Synced data then comes back only from a backup of the account's mnemonic, so ask the user to back it up once they've created data.
  • The first render waits for the device database, and with a passkey, for the user's prompt, on every start and in every tab.
  • Where the browser refuses storage, as Safari's Private Browsing and Firefox's private windows do, Evolu keeps the databases in memory, and each tab has its own account and data.

Summary

Evolu now has local auth. createEvoluApp creates the user's account on the device, keeps it in a device database, and unlocks that database with a passkey on the web, the iOS Keychain or Android Keystore on mobile, or, for apps that choose instant start, a fixed key that anyone can know. Before, every app built on Evolu 8 had to solve this on its own, including creating exactly one account when the app opens in several tabs at once. Evolu now does that in the startup transaction of its database worker, and the app lists, opens, adds, and removes accounts with a few methods.

Local auth shows how to use it, and the examples show complete apps. If something doesn't fit your app, open an issue.