Secure storage with web APIs

Quite a while ago I wondered if it was possible, in a web application, to store data locally, encrypted, with the encryption key derived from a FIDO2/WebAuthn device (because I have a few of them). All of that using standard web APIs.

Context

It was not a completely out-of-the-blue idea, I have encountered a situation where mobile devices were used to collect personal or sensitive data on areas with poor internet connectivity. There was not much control on those devices, so they may or may not have storage encryption enabled. They may also easily be lost or taken/stolen by a potentially malevolent third party.

So one way to solve this problem is to store the data locally, but encrypted directly in the browser. And synchronize later-on with a server.

Scope

Mostly unrelated to the situation explained below, and just out of curiosity, I wanted to do a proof of concept with a few requirements:

The App

So, I needed to encrypt data, but what data? Yep, here is a new instance of a ToDo App (I wonder why coding agents are always showcased by creating ToDo apps… maybe because they are good at it given the huge number of existing ones they have learnt from 😉).

Encryption

The app first checks that the application is running in a compatible web browser, then sets up the encryption system by asking to set a password. Once done, the app asks to log in (by typing the password again).

In this PoC, the interesting part is how to do the encryption; I didn’t do any checks on the password strength, etc.

Master key creation and password derivation

When setting up the encryption, the app generates a random 256-bit master key (the key used to encrypt the data). Then it derives a key encryption key (KEK) from the password using PBKDF2. The KEK is then used to encrypt the master key with AES-GCM and a random initialization vector (IV). The parameters used to derive the KEK and encrypt the master key are then stored in IndexedDB with the encrypted key.

  sequenceDiagram
    actor User
    participant App@{ "type": "control" }
    participant Web Crypto API
    participant IndexedDB@{ "type" : "database" }

    User->>App: set up encryption with `password`
    App->>Web Crypto API: `generateKey` (AES-GCM 256-bit)
    Web Crypto API-->>App: master key
    App->>Web Crypto API: `deriveKey(password)` (PBKDF2, 16B salt, 600,000 iterations)
    Web Crypto API-->>App: derived key (KEK)
    App->>Web Crypto API: `wrapKey(master key, derived key)` (AES-GCM, 12B IV)
    Web Crypto API-->>App: wrapped key
    App->>IndexedDB: store wrapped key + key derivation params

Master key retrieval

When loading the app, it asks to unlock the application with the user’s password. The app retrieves the wrapped key and the key derivation parameters from IndexedDB. With the password and the derivation parameters, it can derive the KEK, and with the KEK it can decrypt the wrapped master key.

  sequenceDiagram
    actor User
    participant App@{ "type": "control" }
    participant Web Crypto API
    participant IndexedDB@{ "type" : "database" }

    User->>App: unlock app with `password`
    App->>IndexedDB: retrieve wrapped key + key derivation params
    IndexedDB-->>App: wrapped key + key derivation params
    App->>Web Crypto API: `deriveKey(password)` (PBKDF2, 16B salt, 600,000 iterations)
    Web Crypto API-->>App: derived key (KEK)
    App->>Web Crypto API: `unwrapKey(wrapped key, derived key)` (AES-GCM, 12B IV)
    Web Crypto API-->>App: master key

Encryption and decryption

OK, now that we have a master key (as a CryptoKey instance), we can encrypt and decrypt data.

Here is the encryption process:

  sequenceDiagram
    actor User
    participant App@{ "type": "control" }
    participant Web Crypto API
    participant IndexedDB@{ "type" : "database" }

    User->>App: add task “Buy milk”
    App->>Web Crypto API: `getRandomValues` (12B IV)
    Web Crypto API-->>App: random iv
    App->>Web Crypto API: `encrypt(data, master key, iv)` (AES-GCM, 12B IV)
    Web Crypto API-->>App: encrypted data
    App->>IndexedDB: store encrypted data + iv

And the decryption process:

  sequenceDiagram
    actor User
    participant App@{ "type": "control" }
    participant Web Crypto API
    participant IndexedDB@{ "type" : "database" }

    User->>App: get list of tasks
    App->>IndexedDB: retrieve all records
    IndexedDB-->>App: list of records (encrypted data + iv)
    loop record
        App->>Web Crypto API: `decrypt(data, master key, iv)` (AES-GCM, 12B IV)
        Web Crypto API-->>App: decrypted data
    end
    App-->>User: list of tasks

WebAuthn key derivation

OK, now we are done with the classic “easy” encryption process. Let’s dive into the WebAuthn part (a shallow dive, I barely understood the whole thing).

As far as I know, WebAuthn is for authentication (hence the name…) and not for encryption. Given that I have security keys, either FIDO, FIDO2/WebAuthn or OpenPGP, I was thinking that it would be nice to use them to encrypt data in the web browser. My first thought was to use the OpenPGP part, since it’s literally made for encryption. But there's no standard web API for that (yet). There’s, in fact, an early draft for smart card support in web browsers.

So next, was the WebAuthn solution. And the problem was already solved:

The interesting idea here is to use the PRF (pseudo random function) of WebAuthn to retrieve a deterministic key. As the name implies, PRF allows generating seemingly random outputs. The key word here is “pseudo”. A pseudo random function is quite deterministic, as it always returns the same output for the same input. Here the input is a salt that we provide and a secret kep inside the security key. So given the same salt, we always get the same key, without ever knowing the secret key.

The secret is initialized in the key when the application is registered on the security key. As a bonus point, the secret will only work for the app web domain. On the other hand, if you ever change your domain name, the security key won’t work, you will have to register it again (hence the password has a backup solution).

Once the key is retrieved from PRF, quite similarly with the password process, the key is derived (with HKDF since the key is already good material, unlike passwords) and then used to wrap the master key.

  sequenceDiagram
    actor User
    participant App@{ "type": "control" }
    participant Web Crypto API
    participant IndexedDB@{ "type" : "database" }

    User->>App: register webauthn dongle
    App->>Web Crypto API: `credentials.create(url, username)` (register app on security key)
    Web Crypto API-->>App: credentialId
    App->>Web Crypto API: get PRF (url, credentialId, salt)
    Web Crypto API-->>App: prf key
    App->>Web Crypto API: `deriveKey(prf key)` (HKDF, 32B salt)
    Web Crypto API-->>App: derived key (KEK)
    App->>Web Crypto API: `wrapKey(master key, derived key)` (AES-GCM, 12B IV)
    Web Crypto API-->>App: wrapped key
    App->>IndexedDB: store wrapped key + key derivation params

Retrieving the master key is a matter of re-fetching the PRF key from the security dongle, and the encryption/decryption process does not change.

Lessons learned

Not using a framework

They do help a lot. Once you write a few components and services, the need of patterns like inversion of control quickly appears. Implementing a simple IoC is not complicated, but it’s easier to use an existing (and more feature-rich) one.

I didn’t do any routing, event bus, etc.; those are definitely harder to implement properly.

Using a proven framework is not a bad thing. But I wonder, in time of agentic coding, if it’s as relevant as before.

WebComponents

Talking about frameworks, I’m a bit disappointed about WebComponent frameworks. Most of the ones I have seen seem unmaintained. Lit has not seen a new version since July 2025, and has been abandoned by Google in October 2025.

About WebComponent themselves, I think they are usable, but a framework leveraging WebComponents is definitely better. I only did a small application, so it’s hard to see how they can scale. There are a few patterns that can be used to implement communication between components and how to share or inherit CSS, and that the kind of things to probably figure out at the beginning of a project.

IndexedDB

By default, IndexedDB is the only API available in the browser that allows to store unlimited data. BUT, you have to ask for persistent storage permission: navigator.storage.persist().

I already shared my point of view about Google’s implementation of IndexedDB permission handling.

Web Crypto API

The API is quite extensive, hundreds of options… making it not very good from a Developer eXperience point of view. This can lead to footguns, and prior knowledge is required to use it properly. For instance, nothing prevents you from reusing the Initialization Vector, and that’s a “Bad Idea”™.

But it’s made in a way that you can probably do everything you want securely. You can do your encryption without ever having access to the actual key, for example (with the right settings). The browser can do the operations for you while keeping the key out of reach of the JavaScript code.

WebAuthn

I have to say that the onboarding with a FIDO2 device is shit. At first on Firefox it would just not work. And on Chromium it asked me to set a PIN. No explanation. And I have been using keys like that for years without ever having to set a PIN.

But once the PIN is set, Firefox was just fine. The only remaining issue is that each time the application is accessed, I have to type the PIN AND touch the dongle. Which is kind of required in this situation, but what I don’t like is that now I have to type the PIN and touch the dongle on websites that didn’t use to ask for it.

The PIN requirement is because I used userVerification: 'required' instead of 'discouraged' in the registration. The thing is that when an app is registered like that, it’s written on the security key. For instance, with my Nitrokey:

❯ nitropy fido2 list-credentials
Command line tool to interact with Nitrokey devices 0.10.0
Please provide pin: 
There are 2 registered credentials
-----------------------------------
login.domain.com: 
- id: <…>
  user: laurent.xxx@domain.com
-----------------------------------
localhost: 
- id: <>…
  user: User
-----------------------------------
There is an estimated amount of 58 credential slots left

As you can see, it tells each domain and login name of all registered credentials. So if no PIN was required, someone finding the security key could list the credentials, go on the websites and log in. Quite a big fail.

CSS

I never liked using JS to do presentation tasks; to me, it should be CSS only. But CSS had quite a lot of shortcomings, but with time it has improved a lot.

“Simple” things like variables, took a very long time to come.

Nested selectors are a nice addition, and they feel very natural to use with WebComponents.

Staying away from JS for presentation tasks has the added benefit of being better optimized by the browser.

I very much liked the “You no longer need JavaScript” blog post. Quite a few good examples. Like a dark/light mode switch in pure CSS… I also liked the relative colors:

:root {
    color-scheme: light dark;
    --color-primary-background: light-dark(#007bff, #0069d9);
    --color-primary-background-hover: hsl(from var(--color-primary-background) h s calc(l + 10));
    --color-primary-background-active: hsl(from var(--color-primary-background) h s calc(l - 20));
}

Maybe it’s just me, but I think it’s cool to just make a color lighter or darker on :hover and :active, without having to manually find out a color code to choose.

Accessibility

That’s a subject I’d like to dig more into at some point. I had a quick look at it thanks to the “Selfish reasons for building accessible UIs” blog post. The case here was to use it to improve testability, and since I used BDD with Playwright, using ARIA attributes can help simplify things.

Behavior Driven Development

Given the scope of this PoC, the BDD part was quite light. Nevertheless, I’m a bit disappointed about the reports generated by Cucumber.

The reports should be usable by product owners, managers, users… but I don’t think they are. The reports look like a list of files, which I don’t think is the good paradigm to present a report.

I’d like to have several levels of grouping, showing the name of the scenario instead of the file name.

There are plugins to generate different types of reports, but most of the plugins I have seen have not been maintained for about 15 years. Also, cucumber changed their log format, so most of the plugins are not compatible anyway.

Conclusion

It’s always a good idea to try something different from the day-to-day work. There were a few things that I wanted to try for a while, and I covered most of them here.

There are plenty of subjects I learned in this PoC that I didn’t talk about in this post. And of course a PoC is not extensive, there is much more to dig into. Like:

Comments Add one by emailing me.