All posts

How CardLift keeps card details on your device

A look at local card storage, passkey authentication, and the encryption keys that connect them.

Saving a card for later should not require sending its details to another database. CardLift’s storage design keeps encrypted card details in the user’s browser and uses passkey authentication to control the normal save and unlock flow.

The architecture separates three things: the card data, the keys that encrypt it, and the authentication that lets a user access it. Here is how those pieces fit together.

What lives where

The encrypted card details stay in IndexedDB on the user’s device, alongside a non-extractable private encryption key. The server stores the public key and an encrypted copy of the AES data key, also called a wrapped key.

That distinction matters: this storage flow does not upload card details to CardLift’s server, but it does send key material in protected form. Saying that everything stays local would miss an important part of the design.

On the device On the server
Encrypted card details Wrapped AES key
Non-extractable RSA private key RSA public key
Plaintext briefly during authorized use Authentication and account records

The public key can wrap a data key, but it cannot unwrap one. The private key needed to do that stays on the device.

Setting up the vault

The user first registers a passkey. The extension then generates an RSA-OAEP key pair through the Web Crypto API and keeps the private key non-extractable. It persists the private CryptoKey in IndexedDB for use across browser sessions, and sends the public key to the server.

A stored CryptoKey is a browser-managed key object, not a raw private-key string. Non-extractable means Web Crypto will not export its raw key material. It does not mean that code with access to the object cannot use it to decrypt, or that the key is necessarily stored in secure hardware. See CryptoKey.extractable and the Web Crypto API.

Saving card details

Before saving a card, the user authenticates with their passkey. The extension generates an AES key and uses AES-GCM to encrypt the card details locally. It then wraps the AES key with the RSA public key.

The two encrypted outputs go to different places: the card ciphertext stays in local IndexedDB, while the wrapped AES key goes to the server. The unwrapped AES key is needed only while performing the cryptographic operation and should not be persisted in plaintext.

AES-GCM also needs an initialization vector (IV), which is stored alongside the ciphertext. Each encryption must use an IV that has never been used with the same key; a 96-bit IV is the recommended size. See AES-GCM parameters.

One implementation detail is worth making explicit: Web Crypto’s wrapKey() exports the data key before encrypting it. The AES key therefore needs to be extractable when it is wrapped. The RSA private key can remain non-extractable. See wrapKey().

After passkey authentication, card details are encrypted and stored locally. The server stores the wrapped AES key. On unlock, the server authorizes retrieval; the local private key unwraps the AES key to decrypt the local card ciphertext.
Card ciphertext and the private key stay on the device. The server holds the wrapped AES key and releases it through an authenticated, authorized session. Open full-size diagram · Mermaid source

Unlocking a saved card

Before each decryption, the user authenticates with their passkey to establish a session. The server verifies that session and checks ownership before returning the wrapped AES key.

The extension reads the encrypted card details from IndexedDB. It uses the local RSA private key to unwrap the AES key, then uses that AES key to decrypt the card details. Plaintext is available locally for the requested action and should be kept out of persistent storage, logs, analytics, and crash reports.

The passkey and the encryption key have separate jobs. WebAuthn proves possession of an authentication credential; it does not directly decrypt the card or turn the separate RSA key into a passkey-protected hardware vault. The requirement to authenticate before every unlock must be enforced by the application and server flow. See the WebAuthn specification.

The security boundary—and its limits

Separating local ciphertext from the server-held wrapped key limits what either storage location exposes on its own. A server database leak does not contain the card ciphertext or the private key needed to unwrap the AES key.

The device remains a trusted part of the system. Malicious code running in the extension’s privileged context may be able to use the private key, capture a data key during an authorized session, or read plaintext. Non-extractability prevents raw-key export through the API; it does not solve client compromise.

Likewise, keeping card details local describes the storage flow. If a user later fills a payment form, the destination page receives the card details. That handoff needs its own explicit user action and destination checks.

Recovery is another consequence of local storage. A synced passkey does not automatically sync an unrelated RSA key or the card ciphertext in IndexedDB. Losing the device or clearing extension data may mean losing access to saved cards unless a separate recovery mechanism exists. Any recovery design needs to make its effect on key custody clear.

The core idea is straightforward: keep card details encrypted on the device, use a separate key for the data, and authenticate access to the wrapped key. The strength of the design depends on preserving those boundaries throughout storage, unlock, and use.

All posts