← All articles

How Skapi Encrypts Your Users' Data

Most backends will tell you your data is "encrypted at rest." What that phrase usually means is that the hosting provider holds a key, the database holds your data, and the two are kept in different rooms. It protects you against someone stealing a hard drive. It does not protect you against the provider, because the provider has both halves.

Skapi 2.0 takes a different position. When you enable record encryption, your users' private data is encrypted inside their browser, with a key derived from their own password, before a single byte travels to us. We store what comes out. We cannot open it.

This article shows exactly how that works, with real ciphertext, real attack attempts, and an honest statement of where the guarantee ends.


1. The problem with server-side encryption

Here is the shape of a conventional encrypted backend:

   User  ---->  Provider's server  ---->  Database
                       |
                       +--> Key Management Service (provider's account)

The server receives plaintext, encrypts it, and stores it. To serve a read, it decrypts. That means plaintext exists on the provider's machines on every request, and the key sits in the provider's own key service.

Ask the question an auditor asks: can the provider read this data? The answer is yes. Not "would they," not "do they have a policy against it." Can they. A subpoena, a rogue employee, a compromised deploy pipeline, or a misconfigured log all resolve to the same answer.

If you are building a service for clients of your own, this matters commercially, not just philosophically. When your customer asks you to prove that you cannot read their records, "we have a policy" is not a proof. "We do not have the key" is.


2. What Skapi does instead

Encryption happens in the browser, in the user's own session, using the Web Crypto API built into every modern browser. The key is derived from the user's login password and never leaves the device.

   User's browser                          Skapi backend
   ---------------                         -------------
   password
      |
      v
   derive key  (never sent)
      |
      v
   encrypt data  ------ ciphertext ----->  store ciphertext
                                              |
   decrypt data  <----- ciphertext -------    |
      ^                                       |
      |                                    (never sees a key,
   derive key                               never sees plaintext)

The backend's role changes from custodian of your secrets to storage for bytes it cannot interpret. That is the entire idea, and everything below is the engineering required to make it survive contact with a real application.

Turning it on is one option at initialization:

const skapi = new Skapi("<Project ID>", { encryption: true });

Then you write records exactly as you always have. There is no key for your end user to manage, no passphrase prompt, no separate vault:

await skapi.postRecord(
    { diagnosis: 'Type 2 diabetes' },
    { table: { name: 'notes', access_group: 'private' } }
);

Logging in unlocks encryption. A page reload stays unlocked. Your application code does not change.


3. The key hierarchy

A single key derived from a password would be a brittle design: change the password and every record would need re-encrypting. Skapi uses a layered hierarchy instead, which is the same pattern used by password managers and full-disk encryption.

   password  (typed by the user, never transmitted)
      |
      |  PBKDF2-HMAC-SHA256, 600,000 iterations, random 16-byte salt
      v
   [ pseudo-random key ]
      |
      |  HKDF-SHA256, bound to (service ID, owner ID, user ID)
      v
   KEK  --wraps-->  MASTER KEY  (random 256 bits, generated in the browser)
                        |
                        +--wraps-->  IDENTITY KEY  (ECDH P-256, for sharing)
                        |
                        +--wraps-->  DEK for record A  --seals-->  record A data
                        +--wraps-->  DEK for record B  --seals-->  record B data
                        +--wraps-->  DEK for record C  --seals-->  record C data

Read it from the bottom up:

  • Every record gets its own data key (DEK). A compromise of one record's key does not touch any other record.
  • The master key wraps every DEK. One key to unlock the account, not one per record.
  • The password wraps the master key. Changing the password re-wraps one small blob. No record is ever re-encrypted, and no data is touched.

The wrapped keys live in a reserved keyring table, addressed by the user's ID. The secrets sit in the user's own private partition. The only thing published is the public half of the identity key, which is public by definition.

Why the HKDF step is not optional. The password derivation is bound to your specific project and user. A keyring blob lifted out of one Skapi project cannot be opened by a key derived in another, even with the correct password. That is a deliberate barrier against cross-project replay.


4. What actually gets stored

This is the part worth seeing rather than being told about. Below is genuine output from the scheme, produced by running Skapi's own primitives.

A user saves this to a private record:

{
  "diagnosis": "Type 2 diabetes",
  "notes": "HbA1c 7.4%",
  "physician": "Dr. Park"
}

This is what lands in the database as record.data, and this is everything we have:

{
  "__skapi_enc__": 1,
  "enc": "A256GCM",
  "kw": "ECDH-ES+A256GCM",
  "anch": "rid",
  "uid": "4bd6c1a2-8e77-4a10-b39c-0f1e2d3c4b5a",
  "rid": "ap21-rec-9f3c7a2e5b184d60",
  "own": "4bd6c1a2-8e77-4a10-b39c-0f1e2d3c4b5a",
  "iv": "v03cn5vBDcfj8CpB",
  "ct": "sQb9Yxqpwf5RlYed4XtiY-MxH5wCB2cw1hK7OUFtINYIbL2mtoLcZhZLaxfKCulz324aTmRSa8hZE2AenDIwqKuSD43LK2aSxFkhp8t1Q0aM4SQbltw6ohi72Q",
  "k": {
    "4bd6c1a2-8e77-4a10-b39c-0f1e2d3c4b5a": {
      "t": "mk",
      "iv": "msvKs1pYjiRr-gfI",
      "ct": "EhPqHTh6xD6i4MjNqsiIUH8h6x8Z-CdxTFRuP1e7QszC0MwXfnf_2V4IHghrD1Zx"
    }
  }
}

Reading the envelope field by field:

Field What it is
__skapi_enc__ Format version marker
enc Payload cipher: AES-256 in GCM mode
kw Key wrapping algorithm for shared records
anch, uid, rid, own Identity binding, explained below
iv The 96-bit nonce for this encryption
ct The ciphertext, including its 128-bit authentication tag
k The recipient map: whose keys can open this record

There is no key in that object. The k map holds the record's data key wrapped under the master key, and the master key is wrapped under a key that exists only in the user's browser. The chain terminates at a password we never receive.

The overhead is modest: 75 bytes of plaintext became 91 bytes of ciphertext, the extra 16 bytes being the GCM authentication tag that makes tampering detectable.


5. How decryption works

Decryption walks the same hierarchy in reverse, entirely in the browser:

   1.  password + stored salt
          |  PBKDF2 (600,000 iterations) + HKDF
          v
   2.  KEK  --unwraps-->  MASTER KEY
          |
   3.     MASTER KEY  --unwraps-->  the DEK found in envelope.k[my user id]
          |
   4.        DEK  --opens-->  envelope.ct, verified against the binding AAD
          |
          v
   5.  { "diagnosis": "Type 2 diabetes", ... }   returned to your code

From your application's point of view none of this is visible:

const res = await skapi.getRecords({ table: { name: 'notes', access_group: 'private' } });
console.log(res.list[0].data); // { diagnosis: 'Type 2 diabetes', ... }

Same call, same shape, same result. The decryption is a hook inside the SDK.

When a session cannot decrypt, nothing throws. A record you have no key for comes back with data: null and an encrypted field carrying the reason (NOT_A_RECIPIENT, NO_SESSION_KEY, BAD_KEY, and so on). One unreadable record never fails a whole page of results.

This is also precisely what a service owner sees. A master account can list and delete any record in its project, and that has not changed. What it gets back is:

res.list[0].data;      // null
res.list[0].encrypted; // { status: 'failed', reason: 'NOT_A_RECIPIENT', recipients: [...] }

Full administrative privilege, no plaintext. That is the claim made concrete.


6. Proof of concept: attacking the stored envelope

Publishing an encryption design without showing it resist attack is marketing. So we took the envelope above and attacked it five ways, in the role of a provider holding a full database dump.

Each attack was run against the real primitives. Every one failed:

Attack Result
Guess the password, one character off Blocked. Master key unwrap fails
Flip a single bit of the ciphertext Blocked. GCM tag check fails
Copy the envelope onto a different record_id Blocked. Binding mismatch
Replay the envelope into a different project Blocked. Binding mismatch
Rewrite the envelope to name a different owner Blocked. Binding mismatch

The last three are the interesting ones, and they are why the envelope carries those identity fields.

AES-GCM is an authenticated cipher. Alongside the data it encrypts, it authenticates a block of associated data (AAD) that is not encrypted but must match exactly at decryption time. Skapi binds the envelope to its context:

AAD = [ version, service ID, owner ID, record owner, anchor type, record ID ]

The practical consequence: an envelope is not portable. It decrypts only in the position it was written to. A provider with write access to the database cannot take a record they are curious about and graft it onto a record they control in order to read it. Move it and the authentication tag fails. Rename the owner and the tag fails. Copy it to another project and the tag fails.

Each recipient's key wrap is bound the same way, with the recipient's own ID mixed in, so one user's wrap cannot be re-pointed at another user.


7. Why HTTPS is not optional

Record encryption only runs on a page served over https. On plain http it refuses to start:

Encryption requires Web Crypto, which needs a secure context (https, or localhost) in the browser, or Node 18 or newer. A page served over plain http has crypto.getRandomValues but crypto.subtle is undefined.

This is enforced by the browser, not by us. crypto.subtle, the interface that performs every key derivation and every encryption, is exposed only in a secure context. On an insecure origin it is simply undefined. There is no flag to set and no polyfill worth trusting.

The browser vendors are right to draw that line, and the reason is worth stating plainly. Client-side encryption protects data after it leaves the page. It cannot protect the page itself. Over plain http, an attacker on the network can rewrite the JavaScript in flight, and code that has been rewritten can simply mail the password somewhere before deriving anything from it. Encrypting inside a page an attacker can edit is theatre.

So the two work together and neither is sufficient alone:

   HTTPS          protects the code and the channel
                  (nobody can tamper with the page or read the traffic)

   Encryption     protects the data at the destination
                  (nobody at the destination can read the data)

localhost and 127.0.0.1 also count as secure contexts, so local development works normally.


8. Sharing, without handing over the key

The obvious objection to client-side encryption is that it breaks collaboration. If only one password can open a record, how do two people read it?

Skapi solves this with public key cryptography rather than by weakening anything. Every user has an ECDH P-256 identity key, generated in their browser, with the private half wrapped under their master key.

To share, the owner calls the ordinary grant API:

await skapi.grantPrivateRecordAccess({
    record_id: rec.record_id,
    user_id: 'the-other-users-id'
});

Behind that call the SDK fetches the recipient's public key, wraps this record's data key to it using ECDH-ES, and writes the result into the envelope's k map. A real wrap, generated in the proof of concept:

"7c9e0d31-2a44-4b8f-9d10-5e6f7a8b9c0d": {
  "t": "ecdh",
  "epk": "BGmAoHO1hqWnpep1iSHsgI6RBPl2LUla1WZ8DfCJNuU7vS7dMYlz9YJHBm0ymRZelkaIl6E1GKOEF_V1flCzUik",
  "iv": "-SA67JCPqNJ34GGR",
  "ct": "NtqkqgBS23vh4GoV6Z3yQKNZ0Jpiycq-ExpavcYQ3QCIi-tI2tPD8j4WiPtFtn1C",
  "fpr": "AHmRltNyME6x26b7"
}

That is 229 bytes added to the record, and it lets exactly one additional person decrypt exactly one record. In the proof of concept the second user recovered the original plaintext using only their own private key and the record as stored.

Three properties fall out of this design:

  • The owner does not need to be online. The wrap sits on the record permanently, so the recipient reads it whenever they like.
  • Sharing grants one record, not an account. What is wrapped is that record's DEK. It reveals nothing about the owner's master key, identity key, or any other record.
  • Revoking rolls the key. removePrivateRecordAccess removes the wrap and re-encrypts the record under a fresh data key, so the revoked user cannot read future versions.

Revocation is forward-only. It cannot un-read what someone has already read. No system can.


9. What we do not claim

An encryption article that only lists strengths is not a security document, and any auditor will treat it as a red flag. Here is the scope, stated the way we would state it under review.

Only data is encrypted, and only on private records. Everything the database queries on stays in plaintext, because that is the only way your queries keep working:

We can see this We cannot see this
record_id, unique_id, timestamps the data payload
table name and access group the contents of attached files
index names and index values
tags, references, the record graph
file names and sizes
which users a record is shared with

For many applications the index values and tags are the sensitive part. Design your schema knowing this. If a diagnosis is your index value, encrypting data has not hidden the diagnosis.

The strength is capped by password entropy. The Web Crypto API offers only PBKDF2. There is no Argon2id and no scrypt, so there is no memory-hard function available to us in a browser. We use 600,000 iterations, which follows current OWASP guidance, but an offline attacker with a dump still gets unlimited guesses. Realistic figures against PBKDF2-HMAC-SHA256 at 600,000 iterations:

User's password One high-end GPU An eight-GPU rig
6 lowercase letters about 2 hours about 16 minutes
8 mixed-case alphanumeric roughly 170 years roughly 22 years
4-word passphrase thousands of years hundreds of years
128-bit recovery code not reachable not reachable

Skapi's default minimum password is six characters. If you are making this promise to your customers, set minPasswordLength and enforce a real password policy at signup. This is the single highest-leverage thing you control:

const skapi = new Skapi("<Project ID>", {
    encryption: { iterations: 600000, minPasswordLength: 12 }
});

Accounts whose password we mint get no protection. OpenID logins and admin-created accounts derive their keys from a password the provider generated. The guarantee is about a secret only the user knows, so where no such secret exists, there is no guarantee. This is a property of the trust model, not an implementation gap.

Sharing rests on a key directory we serve. For a user's own unshared records, the guarantee is pure cryptography: no key comes from our server on that path at all. For shared records, there is one residual risk. When Alice shares with Bob, her browser asks us for Bob's public key. A malicious provider could return a key it controls.

We are direct about this because every end-to-end system with a provider-hosted directory has the same property, Signal and WhatsApp and iMessage included. It is a property of the trust topology, not a flaw peculiar to Skapi. What limits it:

  • It is an active attack, not a passive one. A provider who dumps the database, the storage bucket and every request log still gets nothing. Forging a key means shipping code that does it, which lives in deploy history and source control where it can be audited.
  • It cannot reach unshared records, which never ask the server for a key.
  • It compromises no identity. What it yields is one record's data key.

And it can be closed. The SDK pins peer keys on first sight and refuses a silently changed key. Users can verify fingerprints out of band, exactly like Signal's safety numbers:

await skapi.pinPeerKey({ user_id: 'their-id', fingerprint: 'the-verified-value' });

Setting trustPolicy: 'strict' requires a verified pin before any first share, which removes the trust-on-first-use window entirely.

A forgotten password can mean permanent data loss. This is the honest cost of holding no key. A password reset proves control of an email address, not knowledge of the old password, so there is nothing for us to unwrap the master key with. That is why Skapi mints a one-time recovery code in the browser at enrollment: 128 random bits, never transmitted, with only its wrap stored. Hand it to your user and tell them to keep it.


10. Why client-side is the right answer

Every alternative we considered fails the one test that matters, which is whether you can prove the claim rather than assert it.

Approach Who holds the key Can the provider read your data?
Encrypted at rest (disk level) Provider Yes
Provider-managed KMS Provider Yes
Bring your own key (BYOK) Provider, at use time Yes, while it is in use
Client-side, password-derived The user, in their browser No, absent an offline password break

BYOK is the interesting near miss. You supply the key, which sounds like control, but the provider must load it into memory to encrypt and decrypt. At the moment of use, the provider has it. It improves governance and audit trails, and it does not change the answer to the auditor's question.

Client-side encryption is the only arrangement where the answer changes, because it is the only one where the provider never possesses the key at any point in the lifecycle. The cost is real and we have listed it: lost passwords lose data, index values stay in the clear, and the ceiling is password entropy. We think that trade is the right one for anyone who has to make a promise to their own customers, because it is the only version of the promise that can be verified rather than trusted.

And verification is the point. You do not have to take our word for any of this:

  • The scheme is standard, named primitives: PBKDF2-HMAC-SHA256, HKDF-SHA256, AES-256-GCM, ECDH P-256. No proprietary cryptography, nothing invented here.
  • It runs in your users' browsers, in JavaScript you can read. Open the network tab and watch what leaves the page. You will find the envelope above, and no key.
  • Every claim in this article was produced by running the code, including the ciphertext and the five failed attacks.

In summary

When you enable record encryption on a Skapi project:

  • Your users' private record data is encrypted in the browser, with AES-256-GCM, before it reaches the network.
  • The key chain terminates at a password we never receive, stretched with 600,000 PBKDF2 iterations and bound to your project.
  • Every record has its own key, wrapped under a master key, wrapped under the password.
  • The stored envelope is bound to its record, owner and project, so it cannot be moved, re-owned, or replayed.
  • Sharing wraps one record's key to one recipient's public key, and revoking rolls it.
  • A master account reading its own project gets null, not plaintext.
  • Everything requires https, because encryption inside a page an attacker can rewrite is not encryption.

We built this because we are asked the same question you are asked: can you read my data? For private records on an encrypted Skapi project, we can give the only answer that stands up to scrutiny.

We do not have the key.


Record encryption ships in Skapi 2.0 and is opt-in per project. See the encryption documentation for the full API, recovery codes, encrypted file attachments, and the complete list of error reasons.