Skip to main content

Security

Security Architecture

This page documents how Inkrypt encrypts notes, written against the code that actually ships rather than a general description of good practice. It names every parameter, states exactly what our database holds, and describes the weaknesses of this design as directly as its strengths.

Last reviewed Reviewed by Inkrypt Security Team

The short answer

Notes are encrypted with AES-256-GCM, using a 256-bit key derived from your password with PBKDF2-HMAC-SHA256 at 310,000 iterations and a fresh 16-byte salt. Both steps run in your browser through the Web Crypto API, so what reaches our database is ciphertext, a salt and an IV — never the password, never the plaintext.

Design goal

Inkrypt is built around a single constraint: our servers must never be able to read a note's contents, even under compulsion, even if fully breached, even if we wanted to. Everything else in the design follows from that, including the features we cannot offer.

The way to achieve that is not policy but arithmetic. If the decryption key never exists on our infrastructure, then reading your note is not a matter of access control — it is a matter of breaking AES-256, which nobody can do.

Cryptographic parameters

These are the values currently in use. They are recorded here so the documentation can be checked against behaviour, and so any future change is visible on the changelog.

CipherAES-256-GCM
Key length256 bits
Authentication tag128 bits
IV length96 bits (12 bytes)
IV generationCryptographically random, fresh per operation
Key derivation functionPBKDF2-HMAC-SHA256
KDF iterations310,000
Salt length128 bits (16 bytes)
Salt generationCryptographically random, fresh per operation
Key extractablefalse (non-extractable CryptoKey)
ImplementationWeb Crypto API — crypto.subtle
Execution locationThe user's browser

Why these values

310,000 PBKDF2-HMAC-SHA256 iterations was the figure the OWASP Password Storage Cheat Sheet gave for HMAC-SHA256 when this parameter was chosen. OWASP has since raised its recommendation to 600,000, and we have not yet migrated. Each note records the count it was created under (see below), so raising it for new notes is a configuration change rather than a re-encryption exercise. 12-byte IVs are the standard GCM construction described in NIST SP 800-38D, which avoids the additional GHASH derivation step other lengths require. 16-byte salts exceed the 8-byte minimum in RFC 8018 with negligible cost.

Key derivation

The password you type is never used directly as a key. It is stretched through PBKDF2-HMAC-SHA256 with a fresh 16-byte random salt and 310,000 iterations, producing a 256-bit AES-GCM key.

The derived key is created as a non-extractable CryptoKey. The browser's crypto layer will use it to encrypt and decrypt, but will not export the raw bytes back into JavaScript — including to our own code. That closes an entire category of accidental key leakage through logging or error reporting.

The iteration count is stored alongside each note as a label such as pbkdf2-310000, and decryption uses the parameters the note was created under. This means the count can be raised for new notes without stranding older ones — the mechanism for a future migration is already in place.

Encryption flow

  1. Generate randomness

    A 16-byte salt and a 12-byte IV are generated from the browser's cryptographically secure random number generator. Both are fresh for every single encryption, including every re-save of an existing note.
  2. Derive the key

    PBKDF2-HMAC-SHA256, 310,000 iterations, using the password and the new salt. Produces a 256-bit non-extractable AES-GCM key.
  3. Encrypt

    AES-256-GCM encrypts the UTF-8 encoded note using the derived key and the new IV, producing ciphertext and a 128-bit authentication tag.
  4. Encode and transmit

    Ciphertext, salt and IV are base64-encoded and sent to the server over TLS, together with the KDF parameter label. The password and the plaintext are not part of this request.
  5. Store

    The server writes those four values. It performs no cryptographic operation on the note and holds nothing that could decrypt it.

What the server stores

Per note, exactly four values relating to content. None of them, alone or together, permits decryption.

The complete set of content-related fields stored per note.
FieldContentsSecret?
ciphertextBase64 AES-256-GCM output including auth tagUnreadable without the key
saltBase64, 16 random bytesNo — not secret by design
ivBase64, 12 random bytesNo — not secret by design
kdfParameter label, e.g. pbkdf2-310000No
The complete set of content-related fields stored per note.

Salts and IVs are not secret in any correctly designed system — they exist to guarantee uniqueness, not confidentiality, and both must be available to reproduce decryption. Their security value comes entirely from being fresh and random, which they are.

Alongside these, the server holds a small amount of operational metadata for the note: its slug (which is its URL and therefore never private), a creation timestamp, a last-updated timestamp, and a schema version. That is the complete list. A note record carries no expiry field and no view counter — those belong to share links, which are stored separately and described below. What the server does not hold, for either, is any password, any password hash, any derived key, or any plaintext.

The slug is not encrypted

A note's name becomes its URL and is stored in the clear. URLs appear in browser history, server logs, referrer headers and link previews. Never put sensitive detail in a note name.

If the distinction between this arrangement and the more common one — where a server encrypts data it has already read — is unfamiliar, we set it out at length in client-side vs server-side encryption.

Sharing does not hand out your note's password. A share link is a separate, encrypted snapshot of one note, protected by its own key, and the note it came from is never exposed by it. Because the two use different keys, revoking or expiring a share has no effect on the note, and holding a share link tells the holder nothing about the note's own password.

  1. A share key is chosen in your browser

    If you set a password on the share, that password is the key. If you do not, your browser generates a random one with crypto.randomUUID(). Either way the key is produced on your device — the server never picks it and never receives it.
  2. The snapshot is encrypted before it is sent

    The note's title and body are serialised and encrypted with the same primitives used everywhere else: AES-256-GCM under a PBKDF2-HMAC-SHA256 key at 310,000 iterations, with a fresh random salt and IV for this share alone.
  3. Only ciphertext is uploaded

    The upload carries the ciphertext, salt, IV and parameter label, plus your chosen expiry, view limit and — if you set a share password — a hash of it. It does not carry the key, and it does not carry the note's own password.
  4. The server issues a link secret

    The server generates a random token for the URL and stores only its SHA-256 digest. Someone who reads the database therefore cannot reconstruct working share URLs from it.
  5. For a passwordless share, the key travels in the URL fragment

    The link takes the form /share/<token>#key=<key>. Everything after the # is the fragment, and browsers do not transmit it — it is stripped before the request is built, so it never appears in the request line, in our logs, or in any referrer header.
  6. The recipient's browser reassembles the pieces

    Opening the link fetches the encrypted snapshot from the server, then reads the key from the fragment — or prompts for the share password — and derives the key locally.
  7. Decryption happens on the recipient's device

    The plaintext exists only in that browser tab. We never see it, and we could not produce it if asked.
What a share upload contains, and what it deliberately omits.
Sent to the serverNever sent
Ciphertext of the snapshotThe share key
Fresh salt and IV for this shareThe note's own password
Parameter label (e.g. pbkdf2-310000)Any plaintext
Expiry time and view limit, if setThe URL fragment, on any request
SHA-256 of the share password, if setAnything that could derive the key
What a share upload contains, and what it deliberately omits.

Why the fragment matters

The part of a URL after # was designed to address a location *within* a document, so it is resolved by the browser and excluded from the HTTP request. That behaviour is what lets a passwordless share link carry its own key past our servers without us ever holding it — and it is also why the whole link, fragment included, must be treated as the secret when you send it to someone.

Two properties of this design have real consequences worth understanding before you rely on them, including how share passwords are stored. Both are set out in known limitations we accept.

Transport and application security

  • TLS everywhere. All traffic is HTTPS. HTTP and non-canonical hostnames are 301-redirected to the canonical origin, and HSTS is set with a one-year max-age and includeSubDomains.
  • Content Security Policy, X-Frame-Options: DENY and frame-ancestors 'none' to prevent the interface being embedded and clickjacked.
  • `X-Content-Type-Options: nosniff` to prevent MIME confusion.
  • `Referrer-Policy: strict-origin-when-cross-origin`, so full note URLs are not leaked to third parties in referrer headers.
  • Permissions-Policy disabling camera, microphone, geolocation and interest-cohort.
  • No ads and no indexing on note or share pages. Advertising scripts are loaded only on editorial content pages; the note editor and share gate carry neither ads nor an index directive.

Known limitations

A security page that lists only strengths is marketing. These are the real constraints of this design, in rough order of how much they should affect your decision.

We serve the code that encrypts

This is the fundamental limitation of all browser-delivered cryptography. Your browser fetches our JavaScript on every visit and runs it. A compromised or malicious operator could serve a modified script that captures passwords before encryption. No amount of correct cryptography inside the page defends against the page itself being replaced.

Installed applications with signed, independently verifiable builds are genuinely stronger here. If your adversary is capable of compelling or compromising a service provider at the delivery layer, no browser-based tool — including this one — is the right choice.

Password strength is the binding constraint

PBKDF2 at 310,000 iterations makes each guess expensive; it does not make a weak password strong. An attacker holding exfiltrated ciphertext can attack it offline at their own pace. A note protected by a dictionary word is protected by a dictionary word, whatever the cipher.

Share passwords are stored as a plain SHA-256 hash

When you password-protect a share link, the server stores a single-round, unsalted SHA-256 of that password so it can check what a recipient types. That is a weak way to store a password: it is fast to compute, so it can be attacked with precomputed tables or at very high guess rates, unlike the 310,000-iteration derivation used for the content itself.

What it does not mean: the hash is not the decryption key, and it is not what the ciphertext is protected with. The snapshot is still AES-256-GCM under a PBKDF2 key with a fresh salt. What it does mean is that for a password-protected share, the password you choose is also the content key — so if that password is short or guessable, recovering it from the stored hash recovers the content, and the 310,000 iterations do not save you.

The practical advice follows directly: for a password-protected share, use a long random passphrase rather than a memorable one. If you do not need a second factor on the link, prefer a passwordless share, where the key is a random value generated in your browser and never sent to us at all.

PBKDF2 is not memory-hard

Argon2id resists GPU and ASIC attacks better because it demands memory as well as time. We use PBKDF2 because it is the only password-based KDF exposed natively by the Web Crypto API across all browsers we support, and a native constant-time primitive is preferable to a JavaScript Argon2 for the threats most users face. This is a considered trade-off, not an oversight.

Endpoint compromise defeats everything

Malware or a keylogger on your device observes plaintext as you type, before any encryption occurs. Client-side encryption cannot defend below its own layer.

No independent audit

Inkrypt has not been through a third-party cryptographic audit. The algorithms and parameters are standard and documented here so they can be evaluated, but you should weigh the absence of external review. We would rather state this than let the omission be inferred.

Reporting a problem

If you believe you have found a vulnerability, our responsible disclosure policy explains how to report it and what to expect. We would much rather hear from you than not.

Security FAQ

Can Inkrypt decrypt my notes if legally compelled?

No. We can be compelled to produce what we hold — ciphertext, a salt, an IV and a parameter label. We cannot be compelled to produce a key that has never existed on our systems. This is why the guarantee is architectural rather than a policy promise.

Why is the salt stored unencrypted?

Salts are not secret in any correct design. Their purpose is to make each derivation unique so precomputed tables are useless and cracking one note gives no advantage on another. Both salt and IV must be available to reproduce decryption.

Has Inkrypt been independently audited?

No. The algorithms and parameters are standard and fully documented on this page so they can be evaluated directly, but there has been no third-party cryptographic audit. You should factor that into your assessment.

Why PBKDF2 rather than Argon2id?

Argon2id is the stronger modern choice because it is memory-hard. PBKDF2 is the only password-based KDF the Web Crypto API exposes natively across all browsers we support, and a native constant-time primitive beats a JavaScript implementation for realistic threats. We track this as an open trade-off.

What happens if you raise the iteration count?

Nothing breaks. Each note stores the parameters it was created with, so existing notes continue to decrypt under their original settings while new notes use the new ones.

Do you log IP addresses?

Inkrypt's application code does not read, log or store IP addresses. There is no code path that reads them and no field for them in the database. Your IP address is still necessarily visible to our hosting provider, and to third parties your browser contacts such as Google for advertising and analytics, because a request cannot be answered without it. Those records sit with those providers, are never linked to note contents, which we cannot read, and there are no accounts to associate them with. See the privacy policy for detail.

Verify it yourself

Open your browser's developer tools, watch the Network tab, and save a note. The request body should contain ciphertext — not your text.

Open the notepad