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,
loadDeviceSecretcreates 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,
createPasskeyAuthunlocks aDeviceSecretwith 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
DeviceSecretthemselves. Keeping it with Electron'ssafeStorageis 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.