Every password login you have ever written has a dirty little secret: the server sees the password. Maybe only for a few microseconds, maybe only long enough to run it through bcrypt and throw it away — but it sees it. It’s in a request body, it’s in memory, it’s one stray log.Printf away from a breach report.

OPAQUE breaks that assumption. It’s an asymmetric password-authenticated key exchange (aPAKE), standardized last year as RFC 9807. Client and server run a protocol whose inputs are the password (on the client) and a stored record (on the server), and whose output is a shared session key — without the password, or anything that reveals it, ever crossing the wire. A compromised server learns no passwords. There is nothing to phish from the network. There is no plaintext to accidentally log.

I wanted to actually feel that, so I built opaque-login: a hand-rolled OPAQUE client in TypeScript talking to a Go server, where the protocol log shows you each message and its size going across the wire. You can register, log in, and watch 98 bytes of KE1 leave the browser knowing none of those bytes are your password. This post is the guided tour.

If you want the why — the decades of password-hashing history that led here, from unsalted MD5 to SRP to OPAQUE — that’s the companion post. This one is the how.

The shape of the thing

opaque-login is deliberately small. A vanilla TypeScript client built directly on @noble/curves — no OPAQUE library, every byte assembled by hand so there’s nowhere for the magic to hide. And a Go server on bytemare/opaque with in-memory storage, serving the built web client from an embedded filesystem so the whole demo ships as one binary.

The TypeScript client is wire-compatible with the Go server, which is the part that took the most care: OPAQUE is a precise protocol, and a single byte out of place in a transcript means every login fails its MAC check. More on that landmine later.

The ciphersuite is fixed: P256-SHA256 for the OPRF and the AKE, HKDF-SHA-256, HMAC-SHA-256, SHA-256, and Argon2id as the key-stretching function.

OPAQUE has exactly four messages

Two for registration, two for login. That’s the whole protocol from the wire’s point of view.

sequenceDiagram participant C as Client (browser) participant S as Server (Go) Note over C,S: Registration C->>S: register/init — blinded password (33 B) S-->>C: evaluated element + server public key C->>S: register/finalize — registration record (129 B) Note over S: stores an envelope it cannot open Note over C,S: Login C->>S: login/init — KE1 (98 B) S-->>C: KE2 (259 B) Note over C: verify server MAC C->>S: login/verify — KE3 (32 B) Note over S: verify client MAC → authenticated

Underneath those four messages are three ideas: a blind evaluation so the server can salt your password without seeing it, an envelope the server stores but cannot open, and a 3DH handshake that authenticates both sides and spits out a session key. Let’s take them in order.

Idea 1: the OPRF — salting a password you can’t see

The classic way to harden a stored password is to salt and stretch it: Argon2id(password, salt). But the server has to see the password to compute that. OPAQUE wants the same stretched secret without that exposure, and it gets there with an oblivious pseudorandom function (OPRF).

The mental model: the client wants F(key, password) where key is a secret the server holds. With an OPRF, the client never reveals password and the server never reveals key, yet the client walks away with the result.

It works because elliptic-curve points can be multiplied by scalars. The client hashes its password to a curve point and multiplies by a random blind; the server multiplies by its secret key; the client divides the blind back out. What comes back is F(key, password) and the server saw only a uniformly random point the whole time.

In the client, registration starts exactly there:

export function createRegistrationRequest(password: string): {
  blindedElement: Uint8Array
  state: RegistrationState
} {
  const input = new TextEncoder().encode(password)
  const { blind, blinded } = oprfBlind(input)
  return { blindedElement: blinded, state: { password, blind } }
}

blinded is 33 bytes of compressed P-256 point. That’s what goes to the server. It is information-theoretically independent of the password — the server could stare at it forever and learn nothing.

The server evaluates it with a per-user OPRF key derived from the credential identifier (here, the email) and sends back the evaluated element plus its own public key:

regResp, err := a.server.RegistrationResponse(regReq, []byte(req.Email), nil)

Back on the client, we finalize the OPRF to recover rwd (the “randomized password”), then run it through Argon2id. This is the key-stretching step — the expensive bit that makes a stolen record costly to brute-force:

const rwd = oprfFinalize(input, state.blind, evaluatedElement)
const randomizedPwd = await ksf(rwd)   // Argon2id, m=64MiB, t=3, p=1

randomizedPwd is the seed for everything else. Crucially, it is only computable by someone who holds the password and can get the OPRF evaluated. The OPRF key is the server’s secret, so an attacker can’t precompute a dictionary ahead of a breach — that pre-computation resistance is one of OPAQUE’s signature properties.

Idea 2: the envelope the server can’t open

Now the client builds the thing the server will store. It derives a small family of keys from randomizedPwd with HKDF, mints itself a static AKE key pair, and seals an envelope:

const envelopeNonce = randomBytes(Nn)

const maskingKey = expand(sha256, randomizedPwd, enc('MaskingKey'), Nh)
const seed       = expand(sha256, randomizedPwd, concat(envelopeNonce, enc('PrivateKey')), Nsk)
const authKey    = expand(sha256, randomizedPwd, concat(envelopeNonce, enc('AuthKey')), Nh)

const clientPrivKey = derivePrivKeyFromSeed(seed)
const clientPubKey  = p256.getPublicKey(clientPrivKey, true)

// Envelope = nonce || HMAC(authKey, nonce || cleartext_credentials)
const cleartext = createCleartextCredentials(clientPubKey, serverPublicKey, null, null)
const authTag   = hmac(sha256, authKey, concat(envelopeNonce, cleartext))
const envelope  = concat(envelopeNonce, authTag)   // 64 bytes

The registration record uploaded to the server is just three fields:

RegistrationRecord = client_public_key (33) || masking_key (32) || envelope (64)
                   = 129 bytes

Here’s the elegant part. The envelope is an authenticator, not a ciphertext of the password — there’s no password in it. To use it you have to first reconstruct authKey, which means reconstructing randomizedPwd, which means holding the password and getting the OPRF evaluated. The server stores 129 bytes that are useless to it. It can’t log you in on your behalf; it can only play its part when you bring the password.

The client throws away its private key and randomizedPwd the moment registration finishes. Next time, it re-derives them from scratch.

Idea 3: login is a 3DH handshake wearing a password

Login is where it gets fun. The client sends KE1: a freshly blinded password, a nonce, and an ephemeral public key.

const { blind, blinded } = oprfBlind(input)         // 33 bytes
const clientNonce   = randomBytes(Nn)               // 32 bytes
const clientEphPriv = randomBytes(Nsk)
const clientEphPub  = p256.getPublicKey(clientEphPriv, true)  // 33 bytes

// KE1 = blinded_element || client_nonce || client_keyshare = 98 bytes
const ke1Bytes = concat(blinded, clientNonce, clientEphPub)

The server replies with KE2 (259 bytes), which packs a lot in: the OPRF evaluation again, plus a masked copy of the server’s public key and the user’s envelope, plus the server’s nonce, ephemeral key, and a MAC.

The masking is a nice touch. The server doesn’t hand the envelope out in the clear — it XORs it with a pad derived from the masking_key, which the client can only regenerate by reconstructing randomizedPwd. So an eavesdropper, or someone guessing at an account, can’t even harvest the envelope:

const maskingKey = expand(sha256, randomizedPwd, enc('MaskingKey'), Nh)
const pad = expand(sha256, maskingKey, concat(maskingNonce, enc('CredentialResponsePad')), Npk + Ne)
const unmasked = xorBytes(maskedResponse, pad)
const recoveredServerPubKey = unmasked.slice(0, Npk)
const envelope              = unmasked.slice(Npk)

The client reconstructs authKey, recomputes the envelope’s HMAC, and checks it. A wrong password produces a different randomizedPwd, a different authKey, and the tag simply won’t match — that’s the first place a bad password dies.

Then the actual authenticated key exchange: a 3DH (triple Diffie-Hellman), the same skeleton as the Noise/Signal handshakes. Three DH operations mix the two ephemeral keys and the two static keys so that the resulting secret proves both parties hold the right long-term keys, while the ephemerals give forward secrecy:

const dh1 = p256.getSharedSecret(state.clientEphemeralPriv, serverKeyShare)        // eph  × eph
const dh2 = p256.getSharedSecret(state.clientEphemeralPriv, recoveredServerPubKey) // eph  × S-static
const dh3 = p256.getSharedSecret(clientPrivKey,             serverKeyShare)        // C-static × eph
const ikm = concat(dh1, dh2, dh3)

const akeprk          = extract(sha256, ikm)                          // HKDF-Extract
const sessionKey      = expandLabel(akeprk, 'SessionKey', preamble)
const handshakeSecret = expandLabel(akeprk, 'HandshakeSecret', preamble)
const serverMacKey    = expandLabel(handshakeSecret, 'ServerMAC', empty)
const clientMacKey    = expandLabel(handshakeSecret, 'ClientMAC', empty)

The client verifies the server’s MAC over the transcript — that’s the server proving it knows the right static key, i.e. that you’re not talking to an impostor. Then it sends KE3, its own MAC, 32 bytes, proving it derived the same keys from the same password:

const ke3 = hmac(sha256, clientMacKey, sha256(concat(transcriptBytes, serverMAC)))

The server checks that MAC in constant time and the login is done. Both sides now hold the same sessionKey, mutually authenticated, and the password never existed anywhere but the browser.

if err := a.server.LoginFinish(ke3, clientMAC); err != nil {
    writeErr(w, http.StatusUnauthorized, "authentication failed")
    return
}

A property you get for free: account enumeration resistance

Watch what the server does when an unknown email tries to log in:

if !found {
    // Respond to unknown emails with a fake record so the wire response is
    // indistinguishable from a real one. Login simply fails the MAC check later,
    // exactly as a wrong password would.
    fake, _ := a.cfg.GetFakeRecord([]byte(req.Email))
    clientRecord = fake
}

There’s no “no such user” response to scrape. An unregistered email gets a perfectly well-formed KE2 built from a fake record; the login dies at the MAC check, the same way a real account with a wrong password does. An attacker probing for valid accounts can’t tell the two cases apart. You don’t have to design this — it falls out of the protocol’s structure.

The landmine: the context string

One detail will eat an afternoon if you let it. The 3DH transcript is hashed into a preamble, and that transcript includes a context string that both sides mix in. The client’s constant and the server’s must be byte-for-byte identical:

// client/src/opaque.ts
const CONTEXT = new TextEncoder().encode('opaque-login-demo-v1')
// server/opaque/opaque.go
contextString = "opaque-login-demo-v1"

If they differ by a single byte, the two sides compute different transcripts, their MAC keys diverge, and every login fails authentication — with no error that points at the cause, because a transcript mismatch is indistinguishable from a wrong password. Ask me how I know.

Run it

It’s a single binary that serves the UI and the API on localhost:8080:

make install   # one-time: client deps
make run       # build client + binary, then run

Open the page, register, then log in, and the protocol log narrates every message and its byte count as it crosses the wire. The fun is watching it work and knowing the password isn’t in any of those bytes.

It’s a teaching demo, deliberately stripped to the protocol — in-memory stores, deterministic dev keys, no TLS, no token issuance after login. A real deployment adds all of that. But the cryptographic heart is the real thing, RFC 9807 to the byte, and the whole point is that you can read all 387 lines of the client and see exactly where your password does and doesn’t go.

The code is at github.com/husobee/opaque-login. Clone it, read it, break it.

If you want the long story of how we got from storing passwords in plaintext to a protocol where the password never leaves the browser — salting, stretching, rainbow tables, SRP, and the design pressures that produced OPAQUE — that’s the companion post.

Hope this was helpful to someone.