Most web applications that handle sensitive data follow a familiar pattern: collect it, transmit it over TLS, store it in a database, and promise users you will look after it. The problem with this model is that you — the site operator — can see everything. Your database administrators can query it. A breach exposes it. A subpoena compels it. A rogue employee exfiltrates it.
Today we shipped a different approach for OSA's maturity assessments. Your scores and notes are now encrypted in your browser before they ever reach our servers. We literally cannot read your assessment data. Here is how we built it, and how you can do the same.
The Architecture
The design separates user data into two categories:
- Private data (individual assessment scores, notes, gap analysis) — encrypted client-side, stored as opaque ciphertext
- Aggregate data (anonymous benchmark contributions) — plaintext, opt-in, contains only numeric scores with no identity or notes
This means OSA can compute industry benchmarks from crowd-sourced data without ever seeing any individual's detailed assessment.
How It Works
Key Generation
On first use, the browser generates an AES-256-GCM key using the Web Crypto API:
crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])- The key is exported as JWK and stored in localStorage
- The key never leaves the browser. It is never transmitted to any server.
Encryption
Before saving an assessment, the client:
- Serialises the scores object to JSON
- Generates a random 12-byte IV
- Encrypts with AES-256-GCM
- Formats as
enc:v1:{base64-iv}:{base64-ciphertext} - Computes the overall score (a simple average) client-side
- Sends the encrypted blob and the numeric overall score to the server
The server stores the encrypted string in its database. It cannot decrypt it. It does not have the key.
Decryption
When the user returns to view their assessment:
- The server returns the encrypted blob as-is
- The client detects the
enc:v1:prefix - Decrypts using the key from localStorage
- Renders the scores locally
Analysis Endpoints
Threat analysis, gap analysis, and report generation all require access to individual scores. Since the server cannot decrypt them, these endpoints accept POST requests where the client sends its decrypted scores transiently for server-side computation. The scores are used to compute the analysis response but are never persisted in plaintext.
Benchmark Contributions
When a user opts to share their scores to the benchmark pool, the client:
- Decrypts the assessment locally
- Extracts only numeric scores (no notes, no identity)
- Sends these as plaintext to the benchmark endpoint
- The server stores them anonymously with no link back to the user's assessment
What the Server Sees
After this change, here is exactly what OSA's database contains for a saved assessment:
- Assessment ID: random hex string
- Pattern ID: which pattern was assessed (e.g. SP-029)
- Status: draft, in_progress, or completed
- Encrypted scores:
enc:v1:rAnDoMiV...cIpHeRtExT...(opaque) - Overall score: a single number (e.g. 3.42)
- Timestamps: created and updated dates
That is it. No individual control area scores. No notes about your security posture. No qualitative data whatsoever.
Key Backup and Recovery
The most common concern with client-side encryption is key loss. If you clear your browser data, your encryption key is gone and your saved assessments become unrecoverable ciphertext. We cannot help — we do not have the key.
To address this, we provide key export and import on your assessment dashboard:
- Export: Downloads your encryption key as a small JSON file (
osa-encryption-key.json). Store this somewhere safe — a password manager, an encrypted USB drive, or a secure cloud folder. - Import: Upload a previously exported key to restore access on a new device or after clearing browser data.
The exported file contains your raw AES-256-GCM key in JWK format. Treat it like a password — anyone with this file can decrypt your assessment data. We validate the key format on import (must be kty: oct, alg: A256GCM) and test that it imports successfully via Web Crypto before storing it.
We considered deriving keys from passkeys (WebAuthn PRF extension), which would tie encryption to a hardware authenticator and eliminate the backup problem entirely. However, PRF support is limited to Chrome with compatible authenticators, and corporate environments frequently restrict WebAuthn. We may add this as an option in future, but key export/import provides a universal solution today.
The Trade-offs
This approach has real trade-offs that you should understand:
- Key management is the user's responsibility: We provide export/import tools, but if a user loses their key without a backup, their data is gone. This is inherent to any system where the server does not hold the key.
- No server-side search: We cannot query across encrypted assessment data. We cannot build features like "show me all assessments where Access Control scored below 3" on the server. Any such analysis must happen client-side.
- Trust boundary shifts: The client becomes the trust boundary. If the user's device is compromised, the encryption provides no protection. This is the same trust model as any end-to-end encrypted system.
- Backward compatibility: Existing unencrypted assessments continue to work. The system detects the
enc:v1:prefix to distinguish encrypted from legacy data. On next save, legacy data gets encrypted.
Implementation Guide
If you want to implement this pattern in your own application, here are the key decisions:
1. Choose Your Cipher
AES-256-GCM via Web Crypto API. It is authenticated encryption (detects tampering), widely supported in all modern browsers, and the Web Crypto API handles it natively with no external dependencies.
2. Decide Your Key Strategy
Options range from simple to complex:
- localStorage + export/import (what we use): Simple, works, and users can back up their key as a JSON file. Provides a safety net without adding complexity to the core flow.
- Derived from password: PBKDF2 from a user-chosen passphrase. Survives device changes but requires the user to remember something.
- Key escrow: Encrypt the key with the user's OAuth provider's public key. Enables recovery but introduces a trusted third party.
- Hardware-bound: Use WebAuthn PRF extension to derive keys from a FIDO2 authenticator. Strongest option but limited browser support, and corporate environments frequently restrict WebAuthn.
3. Separate Your Data Flows
Identify which data genuinely needs to be in plaintext for your application to function, and encrypt everything else. For us:
- Plaintext: overall score (for dashboard sorting), pattern ID (for routing), status (for filtering), timestamps
- Encrypted: individual scores, notes, qualitative data
- Separate opt-in flow: anonymous benchmark data (numeric only, no identity)
4. Handle the GET-to-POST Migration
Any endpoint that previously read sensitive data from the database and computed results needs to accept the data from the client instead. We changed our threat analysis, gap analysis, and report endpoints from GET (read from DB) to POST (client provides decrypted data). The GET endpoints still work for legacy unencrypted data but return an error for encrypted assessments, directing clients to use POST.
5. Provide Key Backup
Always give users a way to export and import their encryption key. A downloaded JSON file that they can store in a password manager or secure location is the simplest approach. Validate the key format on import and warn clearly if importing a new key will replace an existing one (making data encrypted with the old key unreadable).
6. Communicate the Model
Users need to understand:
- Their data is encrypted and you cannot access it
- Their encryption key is tied to their browser but can be backed up
- Losing the key without a backup means losing access to saved data
- What data they are sharing (and not sharing) when they opt into aggregate features
Why This Matters
Security assessments reveal an organisation's weaknesses. They contain exactly the information an attacker would want: which controls are immature, which areas are under-invested, where the gaps are. Storing this data in plaintext, even behind authentication, creates a honeypot.
Client-side encryption eliminates this risk at the architectural level. A database breach exposes only ciphertext. A rogue administrator sees only encrypted blobs. A legal request produces data we cannot decrypt. The threat model fundamentally changes when the server is not trusted with the plaintext.
This is not a novel technique. Signal, WhatsApp, and ProtonMail all use variants of client-side encryption. What is less common is applying it to business applications where the temptation to mine user data for product insights is high. We chose not to have that temptation.
Related Reading
- Web Crypto API specification — W3C standard for browser-native cryptography
- OWASP Cryptographic Storage Cheat Sheet — guidance on encryption at rest
- SP-027 Secure AI Integration — our pattern covering data classification and AI security
- NIST SP 800-175B — guideline for using cryptographic standards
The implementation is open source. See the assessment component and API endpoints for the complete code.
If you are building an application that collects sensitive data, consider whether you actually need to see it. Often the answer is no. And when the answer is no, the right architecture is one where you cannot.
The OSA Core Team