OPAQUE Password Authentication in TypeScript: OPRFs, Envelopes, and 3DH
By Oleksii Vasylenko, Technical Lead · Published · Updated · 20 minute read
Topics: OPAQUE, augmented PAKE, OPRF, 3DH, password security, TypeScript, WebCrypto, RFC 9807
Plain English: OPAQUE lets a website check that you know your password without the website ever receiving the password. A successful login leaves your browser and the server holding the same fresh secret key for the session.
Short version. OPAQUE password authentication is an augmented password-authenticated key exchange (aPAKE) standardised as RFC 9807. A client proves it knows a password and both sides end up with a mutually authenticated session key, and the password itself never leaves the client. The protocol is built from four parts: an oblivious pseudorandom function (OPRF), client-side password hardening, a password-protected credential envelope, and an authenticated key exchange (3DH in the implementation I read). Registration stores a credential record made of a client public key, a masking key, and an envelope. During login the correct password reconstructs the client’s private key, unlocks the envelope, and lets both parties authenticate the transcript. OPAQUE does not stop online guessing and does not make weak passwords strong. An attacker who steals the complete server state can still guess offline, but only per user and only after the breach; there is no precomputed table.
A normal password login has an awkward moment that most diagrams hide. After TLS is stripped, the server receives the password in plaintext. Maybe it hashes the value at once. Maybe an application trace records the request body first. Maybe TLS terminates at a reverse proxy outside the security boundary. Either way the password has already crossed into server-controlled memory.
OPAQUE changes that contract. The password stays on the client, the server can still authenticate the client, and both sides finish with the same high-entropy session key. Login does not depend on certificate PKI. Registration still needs an authenticated, confidential channel, and TLS stays as defence in depth.
This article is a source-level study of Cloudflare’s opaque-ts, which is BSD-3-Clause licensed and not my code. I read it; I did not write it. What follows is what I found tracing the full flow: the client and server state machines, OPRF evaluation, envelope recovery, 3DH key agreement, transcript MACs, wire serialisation, fake records for unknown users, and the vector tests. The repository is easy to read because each cryptographic responsibility lives in its own module, and I have quoted the relevant functions where they help.
No cryptography background required
Read the next three sections first if PAKE, OPRF, envelopes, and key exchange are new terms. They explain the idea without curve arithmetic. The later sections repeat the same journey using the actual message names, byte sizes, and TypeScript functions.
OPAQUE in one minute
Start with an ordinary website login. You type a password into the browser. TLS encrypts it across the internet. At the server, TLS is removed and the application gets the original password back, hashes it, and compares the hash with a stored value. The network was protected. The server still saw the password.
OPAQUE moves the password check into a two-party computation. The browser transforms the password into a blinded value before it sends anything. The server applies its own secret key to that blinded value. Only the browser can finish the calculation. If the password is right, the browser recovers its cryptographic identity and both sides derive the same fresh session key. If the password is wrong, the recovered material is garbage and the final authentication checks fail.
One mental model: the server holds a locked credential box and one half of the unlocking procedure. The client holds the password, which is the other half. The server can help the client try to open the box, but cannot see the password used in the attempt. The real code uses elliptic-curve group operations, HKDF, HMAC, and an authenticated envelope rather than a literal box, but the shape is the same.
password stays on client
|
v
blind password ---------> server applies secret OPRF key
|<------------- server returns evaluated element
v
unblind + run scrypt
v
recover client credentials from envelope
v
run mutual 3DH authentication with server
v
both sides obtain the same fresh session keyOne sentence to remember: the password unlocks a client identity, and that identity performs the login. The server checks a cryptographic proof produced by that identity instead of hashing a password it received.
Plain-English glossary
Protocol writing becomes unreadable when every noun is an acronym. These are the terms used through the rest of the article.
- Hash function
- A one-way transformation from input bytes to a fixed-size result. The same input produces the same result. Hashing alone does not stop password guessing.
- Salt
- A unique value mixed into password hashing so two users with the same password do not have the same stored result. A normal salt is public. OPAQUE uses a server-secret OPRF contribution instead.
- Password hardening or KSF
- An intentionally expensive calculation such as scrypt or Argon2 that makes every password guess consume more time and memory. KSF stands for key stretching function.
- PAKE
- Password-Authenticated Key Exchange. Two parties use a password to authenticate and create a strong shared key without placing the password itself on the wire.
- Augmented PAKE or aPAKE
- A client-server PAKE where only the client knows the password. The server stores a credential record instead of a password-equivalent secret.
- OPRF
- Oblivious Pseudorandom Function. The client supplies a hidden input, the server supplies a secret key, and only the client learns the output.
- Blinding
- Randomising the password-derived group element before the server sees it. The client can later remove the randomisation; the server cannot inspect the original input.
- Credential envelope
- Password-protected authentication material stored by the server for the client. In opaque-ts it is 64 bytes: a nonce and an HMAC tag. A correct password recreates the keys needed to verify it.
- AKE
- Authenticated Key Exchange. A protocol where both parties prove who they are and agree on a new shared session key.
- 3DH
- Triple Diffie-Hellman. Three key-agreement calculations combine long-term identity keys with fresh one-time keys.
- MAC
- Message Authentication Code. A keyed integrity check proving that a message came from someone holding the expected secret and was not changed.
- Session key
- A fresh high-entropy key agreed during one login. It can authenticate or encrypt later application traffic. It is not the password.
- Forward secrecy
- Protection for old session keys if a long-term key or password is exposed later. Past recorded sessions should not become readable from the later leak.
Alice and Bob: the whole story
Cryptography papers call the user Alice and the server Bob. Here Alice is a browser user creating an account at Bob’s service. She picks correct horse battery staple as her password. Those words never need to reach Bob.
Account creation
- Alice’s browser converts the password to bytes and blinds it with fresh randomness.
- Bob receives only the blinded group element. He derives an OPRF key tied to Alice’s credential identifier and evaluates the blinded element with it.
- Alice receives the evaluation, removes her blinding, and runs the result through scrypt. Bob cannot reproduce this value from the message he saw.
- Alice derives an authentication key, an export key, a masking key, and a stable client key pair from that hardened value. The private key is derived, not stored; the envelope holds only a nonce and a tag.
- Alice computes the envelope tag and uploads a registration record. Bob stores it under her account.
Every later login
- Alice blinds the entered password again and creates a fresh one-time key share. This is
KE1. - Bob evaluates the blinded password, masks the stored envelope, creates his own fresh key share, and computes a MAC proving he holds the server keys. This is
KE2. - Alice unblinds the OPRF result and tries to recover her credentials. A wrong password derives the wrong keys, so the envelope tag does not verify and she stops here.
- With the right password, Alice verifies Bob’s MAC and sends her own in
KE3. - Bob verifies
KE3. Both now hold the same session key.
What Bob stores
| Stored item | Purpose |
|---|---|
| Server OPRF seed | Derives the secret OPRF key for each credential identifier. One seed for the whole server. |
| Server AKE key pair | Bob’s long-term cryptographic identity for 3DH. |
| Client public key | Lets Bob authenticate the client during 3DH. |
| Masking key | Hides the server public key and envelope inside the login response. |
| Envelope | The nonce and authentication tag the client needs to recover its credentials. |
Bob does not store Alice’s password, her client private key, the finished OPRF output, or any previous session key. Keeping the OPRF seed and the server AKE private key separate from the credential database preserves a useful distinction: a stolen database is not automatically a complete server compromise.
OPAQUE does not make a breach harmless. It removes the shared structure an attacker would otherwise amortise across every account in the dump.
What goes over the wire, byte by byte
Everything above is prose. This section is what messages.ts actually encodes, for the P-256 suite (OpaqueID.OPAQUE_P256, which is P256-SHA256). Every message is a fixed-size concatenation with no length prefixes and no framing, so the parser is a sequence of slice calls against constants from the suite configuration.
| Constant | Meaning | P-256 bytes |
|---|---|---|
| Noe | Serialised OPRF group element (compressed point) | 33 |
| Npk | AKE public key (compressed point) | 33 |
| Nsk | AKE private key (scalar) | 32 |
| Nn | Nonce | 32 |
| Nseed | Key derivation seed | 32 |
| Nh | Hash output (SHA-256) | 32 |
| Nm | MAC output (HMAC-SHA256) | 32 |
| Nx | HKDF extract output | 32 |
With those numbers, the four persistent or transmitted structures look like this. Field order is the order serialize() joins them.
Envelope = nonce[32] || auth_tag[32] 64 bytes
RegistrationRequest = blinded_element[33] 33
RegistrationResponse = evaluation[33] || server_public_key[33] 66
RegistrationRecord = client_public_key[33] || masking_key[32] || Envelope 129
KE1 = CredentialRequest || AuthRequest
= blinded_element[33] || client_nonce[32] || client_keyshare[33] 98
KE2 = CredentialResponse || AuthResponse
= evaluation[33] || masking_nonce[32] || masked_response[97]
|| server_nonce[32] || server_keyshare[33] || server_mac[32] 259
KE3 = client_mac[32] 32The 97-byte masked_response inside KE2 is the server public key (33) and the envelope (64), XORed with a pad the client can only regenerate from the right password. So the whole login costs 389 bytes on the wire, and the server’s stored record is 129 bytes plus whatever the application wraps around it. CredentialFile is that wrapper in the tests: a 16-bit length-prefixed credential identifier, the record, and an optional client identity.
Who computes what, with the actual method names:
- Client, registration.
OpaqueCoreClient.createRegistrationRequestblinds the password bytes with@cloudflare/voprf-tsand returns the 33-byte request plus a blind scalar the client keeps in memory. - Server, registration.
OpaqueCoreServer.createRegistrationResponsecalls the privatedoOPRFEvaluation, which expands the server seed withcredential_identifier || "OprfKey"to a 32-byte seed, derives a per-user OPRF key from it, and evaluates the blinded point. It appends the server public key. - Client, registration finish.
OpaqueCoreClient.finalizeRequestunblinds, runs scrypt over the OPRF output, and computesrandomized_pwd = HKDF-Extract(zeros, oprf_output || stretched). The module-privatestore()then draws a 32-byte envelope nonce, callsexpand_keysto deriveauth_key,export_key, and a 32-byte seed for the client key pair, MACs the cleartext credentials underauth_key, and derives the masking key with the label"MaskingKey". The output is the 129-byte record. - Client, login start.
createCredentialRequestblinds the password again, thenAKE3DHClient.startdraws a 32-byte nonce and a fresh ephemeral key pair and assemblesKE1. - Server, login response.
createCredentialResponseevaluates the OPRF, draws a masking nonce, expands the stored masking key into a 97-byte pad, and XORs it overserver_public_key || envelope. ThenAKE3DHServer.responsemakes its own nonce and ephemeral pair, builds the preamble, runs the three DH operations, derives the keys, and signs the hashed preamble underKm2. It keeps anExpectedAuthResult(expected client MAC and session key) in memory untilKE3arrives. - Client, login finish.
recoverCredentialsrepeats the OPRF finalisation and scrypt, regenerates the pad, unmasks the server key and envelope, and callsrecover(), which re-derivesauth_keyand checks the tag. A wrong password returnsError('EnvelopeRecoveryError')here, before any DH work. OtherwiseAKE3DHClient.finalizeverifies the server MAC and signsKE3underKm3. - Server, login finish.
AKE3DHServer.finishcompares the received MAC with the expected one usingctEqualand, only on success, returns the session key.
What OPAQUE password authentication actually solves
Salted password hashing protects stored credentials. It does not stop the application from seeing each login password, and a full database leak hands an attacker the salt and verifier needed to test guesses offline. Memory-hard functions such as Argon2 or scrypt make each guess more expensive, which helps, but the server still handles the secret on every login.
A symmetric PAKE lets two parties who share a password establish a strong key without revealing the password on the wire. That is the wrong storage model for a public service, because the server would need the password itself or an equivalent secret. An augmented PAKE (aPAKE) gives the server a credential record instead. OPAQUE is a strong aPAKE designed to resist precomputation attacks after server compromise.
| Property | Password over TLS | Salted hash | OPAQUE |
|---|---|---|---|
| Server sees login password | Yes | Yes, before hashing | No |
| Produces an authenticated session key | TLS does | TLS does | Protocol output |
| DB-only leak exposes a verifier | Depends on storage | Yes | Credential record only; server secret is separate |
| Full server compromise permits guessing | Yes | Yes | Yes, but per user and only after the compromise |
The last row is the one to read twice. OPAQUE does not make weak passwords strong, does not stop online guessing, and does not turn a compromised server into a non-event. Its sharper claim is that an attacker holding the server’s secret material and the credential file must attack users one at a time, after the compromise. There is no public salt that would let the attacker do the expensive work years in advance.
The four pieces of OPAQUE
1. An oblivious pseudorandom function
An OPRF evaluates a keyed function where the input and the key are held by different parties. The client has the password. The server has an OPRF seed from which it derives a credential-specific key. The client blinds the password into a group element, the server evaluates that element, and the client unblinds the response. The client learns the function output. The server learns nothing about the input.
Client(password) Server(oprf_seed)
| |
blind(password) |
|---- blinded element --------------------->|
| derive key for user
| evaluate(blinded)
|<--- evaluated element --------------------|
finalize(password, blind, evaluation)
|
OPRF outputThis is a two-party computation rather than encryption of the password. The server’s contribution acts like a secret salt, and blinding prevents the server from seeing the client input. RFC 9497 specifies the OPRF family that modern OPAQUE constructions use, and opaque-ts gets it from @cloudflare/voprf-ts through a thin OPRFBaseMode wrapper in common.ts.
2. A key-stretching step
The raw OPRF output is high entropy, but the password behind it may be terrible. The client runs a key-stretching function before deriving its randomised password. In this codebase the default is scrypt with N = 32768, r = 8, p = 1, called with an empty salt (the OPRF already provides the per-user secret):
export const ScryptKSFFn: KSFFn = {
name: 'scrypt',
harden: (msg: Uint8Array): Uint8Array =>
scrypt(msg, new Uint8Array(), { N: 32768, r: 8, p: 1 })
} as constThat cost lands on the client. A browser should run it in a worker, and a Node.js service that acts as a client should measure the event-loop impact. Synchronous password hardening on the main thread is a self-inflicted availability bug. The test suite swaps in an IdentityKSFFn so the vectors run fast; do not copy that into production.
3. A credential envelope
The stretched OPRF result feeds four HKDF expansions: an authentication key, an export key, a masking key, and a deterministic seed for the client’s AKE key pair. The envelope itself stores only a random nonce and an HMAC tag over the server public key and both identities. A correct password reproduces the same key material and verifies the tag. A wrong password produces unrelated bytes, and recover() returns an Error whose message is EnvelopeRecoveryError.
async function expand_keys(cfg, randomized_pwd, envelope_nonce) {
const auth_key = await cfg.kdf.expand(randomized_pwd,
joinAll([envelope_nonce, LABELS.AuthKey]), cfg.hash.Nh)
const export_key = await cfg.kdf.expand(randomized_pwd,
joinAll([envelope_nonce, LABELS.ExportKey]), cfg.hash.Nh)
const seed = await cfg.kdf.expand(randomized_pwd,
joinAll([envelope_nonce, LABELS.PrivateKey]), cfg.constants.Nseed)
const client_ake_keypair = await cfg.ake.deriveDHKeyPair(seed)
return { auth_key, export_key, client_ake_keypair }
}The server stores the envelope but cannot open it. The client can use export_key for application data that should be recoverable from the password-derived secret without being the login session key; it is returned from both registerFinish and authFinish.
4. An authenticated key exchange
Recovering a client private key is not enough on its own. Client and server still need fresh key material, mutual authentication, transcript binding, and forward secrecy. The implementation uses 3DH: three elliptic-curve Diffie-Hellman results combining long-term and ephemeral keys. HKDF turns their concatenation into two MAC keys and the session key.
Registration, message by message
Registration is three messages. The client sends a blinded password element. The server evaluates it and returns the result together with its AKE public key. The client finalises the OPRF, hardens the output, builds its envelope and key pair, and uploads the record.
Client Server
| |
| RegistrationRequest(blinded password) ---->|
| | derive per-user OPRF key
|<---- RegistrationResponse(evaluation, pkS) |
| |
| finalize OPRF + scrypt |
| derive envelope, client key pair, |
| masking key, export key |
| |
| RegistrationRecord(pkU, masking, envelope)>|
| | store credential recordIn code, OpaqueClient.registerInit() encodes the password, blinds it, and parks the blind and the password bytes in a one-use state machine:
async registerInit(password: string): Promise<RegistrationRequest | Error> {
if (this.status !== OpaqueClient.States.NEW) {
return new Error('client not ready')
}
const password_uint8array = new TextEncoder().encode(password)
const { request, blind } =
await this.opaque_core.createRegistrationRequest(password_uint8array)
this.blind = blind
this.password = password_uint8array
this.status = OpaqueClient.States.REG_STARTED
return request
}OpaqueServer.registerInit() delegates to OpaqueCoreServer.createRegistrationResponse(). The client’s registerFinish() calls OpaqueCoreClient.finalizeRequest(), returns a RegistrationRecord plus export_key, and calls clean() to drop the password and blind.
Registration is the stage that needs a server-authenticated, confidential channel. OPAQUE removes the PKI dependence from later logins, but it cannot authenticate a server the client has never met. Run registration over TLS, and pass the intended server and client identities into registerFinish so they are bound into the envelope tag.
Login: KE1, KE2, and KE3
Login runs credential recovery and the authenticated key exchange together. KE1 carries a new blinded password request, a client nonce, and an ephemeral client key share. The server answers with KE2: the OPRF evaluation, a masked credential response, a server nonce, an ephemeral server key share, and a server MAC.
The client finalises the OPRF again. With the correct password it reconstructs the masking key, unmasks the server public key and envelope, derives the same client key pair as at registration, and checks the envelope tag. Then both sides compute the same three DH terms. The client-side call, from 3dh_client.ts:
const ikm = tripleDH_IKM(this.config, [
{ sk: this.client_secret, pk: ke2.auth_response.server_public_keyshare },
{ sk: this.client_secret, pk: server_public_key },
{ sk: client_private_key, pk: ke2.auth_response.server_public_keyshare }
])Read the pairs as (client ephemeral, server ephemeral), (client ephemeral, server static), (client static, server ephemeral). The server computes the mirror image with its own secrets. Each scalar multiplication is serialised as a compressed point and the three results are concatenated into the input keying material. Then deriveKeys in common.ts does the following:
prk = HKDF-Extract(salt = zeros[Nh], ikm)
handshake_secret = Expand-Label(prk, "HandshakeSecret", Hash(preamble))
session_key = Expand-Label(prk, "SessionKey", Hash(preamble))
Km2 = Expand-Label(handshake_secret, "ServerMAC", "")
Km3 = Expand-Label(handshake_secret, "ClientMAC", "")
server_mac = HMAC(Km2, Hash(preamble))
client_mac = HMAC(Km3, Hash(preamble || server_mac))The preamble is what ties the fresh keys to everything the two sides have said so far. preambleBuild concatenates the version label OPAQUEv1-, the application context, the client identity, the full serialised KE1, the server identity, the credential response, the server nonce, and the server key share. Change any byte and both MACs fail.
After verifying the server MAC, the client sends KE3 with its own transcript MAC. The server compares it with the value it precomputed in AKE3DHServer.response, in constant time, and only then hands the session key to the application. Three messages, explicit authentication in both directions, and no password on the wire.
How the TypeScript code maps to the protocol
The public API is small. OpaqueClient exposes two registration methods and two authentication methods. OpaqueServer mirrors those phases. Below them, the repository separates credential recovery from 3DH, which is the right boundary for reading and testing protocol code.
opaque_client.tsandopaque_server.tsown session state and the application-facing flow. The client has three states:NEW,REG_STARTED,LOG_STARTED.core_client.tscreates and recovers the credential envelope.core_server.tshandles OPRF evaluation and response masking; it is 64 lines.3dh_client.tsand3dh_server.tsderive the shared key material and produce or verify the transcript MACs.common.tsdefines the labels, the OPRF wrapper, the preamble, HKDF derivation, andtripleDH_IKM.messages.tsowns the fixed-size wire encoding shown above, pluscreateFakeRecord. Serialisation is security code here, since a wrong slice boundary silently changes what gets MACed.suites.tsconnects P-256/SHA-256, P-384/SHA-384, and P-521/SHA-512 to WebCrypto HMAC, HKDF, and@cloudflare/voprf-ts.
The tests cover full registration and login, message serialisation, HKDF behaviour, credential storage, and protocol vectors across all three suites. Test vectors matter more than line coverage for a protocol implementation. Code can execute every branch and still be byte-for-byte incompatible with every other implementation.
A version warning hidden in plain sight
The repository identifies itself as package version 0.9.0. Its README links OPAQUE draft v07, while the test fixture is named vectors_v16.json.gz. The final CFRG publication is RFC 9807, published in 2025. Do not infer final-RFC wire compatibility from the package name or from passing local tests. Pin the protocol version you are implementing, compare against the RFC 9807 vectors, and run cross-implementation tests before production use.
Security boundaries and failure modes
OPAQUE does not replace TLS everywhere
The RFC requires a confidential, authenticated registration channel. TLS also hides account identifiers and protocol metadata, limits active network interference, and fits existing operational controls. OPAQUE stops the server from seeing the password during login. It is not a reason to serve login traffic over plaintext HTTP.
User enumeration needs a fake-record path
If an unknown username causes an immediate error while a real user triggers OPRF, masking, and 3DH work, the timing difference is an account oracle. The vector tests construct a fake record and install it as the database default, so an unknown user still gets a full KE2:
// To prevent Client enumeration, the server stores a fake record in
// advance to be use when a non-registered user tries to login.
const fake_record = await RegistrationRecord.createFakeRecord(cfg)
const credential_file = new CredentialFile(
FAKE_CREDENTIAL_IDENTIFIER, fake_record, FAKE_CLIENT_IDENTITY
)
database.set_default(FAKE_CREDENTIAL_IDENTIFIER,
Uint8Array.from(credential_file.serialize()))
// createFakeRecord: a random public key, a random masking key,
// and an all-zero envelope.
static async createFakeRecord(cfg: Config): Promise<RegistrationRecord> {
const { public_key: client_public_key } = await cfg.ake.generateDHKeyPair()
const masking_key = new Uint8Array(cfg.prng.random(cfg.hash.Nh))
const zero_envelope_bytes = new Array<number>(Envelope.sizeSerialized(cfg)).fill(0)
const envelope = Envelope.deserialize(cfg, zero_envelope_bytes)
return new RegistrationRecord(cfg, client_public_key, masking_key, envelope)
}A real integration has to keep that behaviour, return indistinguishable errors, and rate-limit known and unknown accounts alike. Note that the fake record should be generated once and stored, not regenerated per request, or the randomness itself becomes a timing signal.
JavaScript cannot promise secret erasure
OpaqueClient.clean() sets the password and blind fields to undefined after a completed exchange. That is good lifecycle hygiene, but garbage-collected runtimes copy buffers and choose when to reclaim them, so this is not verified zeroisation. Keep secret lifetimes short, keep them out of logs and crash dumps, and isolate the cryptographic work where you can.
The client and server objects are stateful
A client instance permits one active registration or login. The 3DH server instance stores the expected client MAC and the session key between KE2 and KE3. Reusing one object across concurrent requests risks state collision. Treat each handshake as its own state machine, bind it to an expiring server-side session, and reject replayed or out-of-order messages.
Uniform errors are part of the protocol surface
Wrong password, malformed group element, invalid envelope, unknown account, and failed transcript MAC are useful distinctions inside a secured diagnostic channel. They should not become five public responses with five timings. The application layer has to collapse authentication failure into one observable result and keep the detail in redacted internal telemetry.
What OPAQUE is not
Security terminology overlaps just enough to create bad mental models. OPAQUE has one job: password-based mutual authentication and key agreement.
- Not a zero-knowledge proof system. The server learns no password, but the construction is an OPRF, credential recovery, and an AKE. There is no SNARK anywhere.
- Not a passkey. WebAuthn authenticates with a device-held public-key credential and can remove passwords entirely. OPAQUE improves authentication for systems that still use a human-memorable password.
- Not a password manager. It does not create, remember, or synchronise passwords for the user.
- Not protection for a compromised client. Malware or a hostile browser extension can read the password before the protocol starts.
- Not automatic application encryption. The login produces a session key. The application decides how to use it and which messages it protects.
- Not permission to skip rate limiting. A live server still answers password guesses one attempt at a time.
Production checklist for an OPAQUE TypeScript deployment
- Target RFC 9807 explicitly. Record the suite, OPRF mode, wire format, labels, and dependency versions. Never call a floating draft simply “OPAQUE.”
- Run final-RFC and cross-language vectors. Test registration, real login, wrong passwords, unknown users, malformed points, altered identities, and a changed context string.
- Keep registration on authenticated TLS. Pass the intended server identity into the protocol as well, so it is bound into the envelope tag and the preamble.
- Move scrypt off the UI thread. Benchmark memory and latency on low-end devices. Tune the parameters as a security and product decision rather than copying the constant.
- Use a stored fake record for unknown accounts. Keep public errors and computational paths as similar as you can.
- Rate-limit online guesses. OPAQUE constrains offline attacks only.
- Isolate handshake state. Give each login its own client and server objects, an expiry, replay protection, and a request correlation identifier.
- Protect the OPRF seed and server AKE key separately from credential records. A database-only breach and a full server-secret breach should stay two different events.
- Audit dependencies and generated bundles. Review group-element validation, constant-time comparison, RNG use, browser polyfills, bundler substitutions, and supply-chain controls.
Source notes and further reading
Everything above comes from reading the linked TypeScript repository at its main branch and the final protocol documents. The source tree retains Cloudflare’s BSD-3-Clause copyright and package identity. My repository link is a research snapshot, not a claim that I authored the upstream implementation.
- RFC 9807: The OPAQUE Augmented PAKE Protocol
- RFC 9497: Oblivious Pseudorandom Functions
- OPAQUE: An Asymmetric PAKE Protocol (Jarecki, Krawczyk, Xu)
- cloudflare/opaque-ts: the implementation reviewed here (BSD-3-Clause)
- My read-only research snapshot of that tree
- Cloudflare: OPAQUE and password authentication
Related reading on this site
- Zero-knowledge proof systems and circuit security →
Production zk-SNARK work and Circom circuit audits, the other side of my applied cryptography practice.
- Lattice cryptanalysis in Rust →
LLL, BKZ, and sieving implementations, including the attacks that recover keys from partial nonce leakage.
- All engineering write-ups →
Matching engines, durable workflow engines, and the benchmarks behind them.