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.
The short answer
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.
| Cipher | AES-256-GCM |
|---|---|
| Key length | 256 bits |
| Authentication tag | 128 bits |
| IV length | 96 bits (12 bytes) |
| IV generation | Cryptographically random, fresh per operation |
| Key derivation function | PBKDF2-HMAC-SHA256 |
| KDF iterations | 310,000 |
| Salt length | 128 bits (16 bytes) |
| Salt generation | Cryptographically random, fresh per operation |
| Key extractable | false (non-extractable CryptoKey) |
| Implementation | Web Crypto API — crypto.subtle |
| Execution location | The user's browser |
Why these values
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
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.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.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.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.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.
| Field | Contents | Secret? |
|---|---|---|
| ciphertext | Base64 AES-256-GCM output including auth tag | Unreadable without the key |
| salt | Base64, 16 random bytes | No — not secret by design |
| iv | Base64, 12 random bytes | No — not secret by design |
| kdf | Parameter label, e.g. pbkdf2-310000 | No |
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
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.
How share links work
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.
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 withcrypto.randomUUID(). Either way the key is produced on your device — the server never picks it and never receives it.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.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.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.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.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.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.
| Sent to the server | Never sent |
|---|---|
| Ciphertext of the snapshot | The share key |
| Fresh salt and IV for this share | The note's own password |
| Parameter label (e.g. pbkdf2-310000) | Any plaintext |
| Expiry time and view limit, if set | The URL fragment, on any request |
| SHA-256 of the share password, if set | Anything that could derive the key |
Why the fragment matters
# 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: DENYandframe-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?
Why is the salt stored unencrypted?
Has Inkrypt been independently audited?
Why PBKDF2 rather than Argon2id?
What happens if you raise the iteration count?
Do you log IP addresses?
Related reading
Threat model
Which adversaries this architecture defeats, and which it explicitly does not.
Read moreZero-knowledge encryption
How to verify a zero-knowledge claim on any service, including this one.
Read moreResponsible disclosure
How to report a vulnerability and what happens next.
Read moreEncryption glossary
Every term on this page, defined in plain English.
Read moreVerify 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