OPAQUE
OPAQUE is a password-authenticated key exchange that gives a server nothing to steal: not the password, not a dictionary-attackable verifier. Learn the oblivious pseudorandom function at its core, the envelope that hides a client's key pair, and the 3DH handshake, with Python code that reproduces the official RFC 9807 test vectors.
Pashalis Laoutaris 30 min read
Try it
Interactive OPAQUE Password-Authenticated Key Exchange Visualizer
🔐 OPAQUE
OPAQUE
Introduction
Every password login has the same weak point: the server has to check the password somehow, which usually means it stores something derived from it. A stolen database of password hashes is an offline dictionary attack waiting to happen. SRP fixed part of this problem in 1998 by replacing the password with a verifier that is never sent over the wire. The verifier itself is still a value an attacker can run a dictionary attack against once they have it, tied to a fixed, known salt and hash function chosen in advance.
OPAQUE is a newer answer to the same problem, published by the IRTF as RFC 9807 in 2025. It is an augmented password-authenticated key exchange. The client proves it knows a password and both sides end up with a shared session key. The server never sees the password, not even during registration, and what it stores cannot be tested against password guesses without the server’s own secret OPRF key. Even with that key, no table of guesses can be computed in advance. This guide builds OPAQUE’s P256-SHA256 configuration from the RFC, including the oblivious pseudorandom function it is built on, and checks the result against the RFC’s own published test vectors.
Table of Contents
- History and Standards
- Why Not Just Hash the Password?
- The Oblivious Pseudorandom Function
- Registration
- The Envelope
- Login: Credential Retrieval and 3DH
- Client Enumeration
- Interactive Visualizer
- A Worked Example
- Python Implementation
- Where It Stands
- Limitations
- Security Status
- FAQ
- References
History and Standards
OPAQUE was first described in a 2018 paper by Stanislaw Jarecki, Hugo Krawczyk, and Jiayu Xu. Bourdrez, Krawczyk, Lewi, and Wood then wrote it up as a specification in the Crypto Forum Research Group (CFRG), and it became RFC 9807 in July 2025. The RFC is Informational. It states that it represents the consensus of the CFRG, and that it is not an IETF product and not a standard.
OPAQUE depends on two other recent RFCs: RFC 9497, which specifies oblivious pseudorandom functions over prime-order groups, and RFC 9380, which specifies how to hash an arbitrary byte string to a point on an elliptic curve. Both of those are themselves young, from 2023.
RFC 9807 defines OPAQUE as a generic framework and then fixes one concrete instance, OPAQUE-3DH, which is the handshake almost every implementation uses. The RFC lists recommended configurations built on the curve ristretto255 or on NIST P-256. This guide implements the P-256 one, because its arithmetic is ordinary short Weierstrass elliptic curve arithmetic, the same kind this site’s ECDH and ECDSA guides already use. Ristretto255 needs its own encoding on top of a different curve shape.
Why Not Just Hash the Password?
A server that stores hash(password) is storing something an attacker can attack offline the moment they read it from a breached database: try every password in a dictionary, hash each one, and compare. Salting slows this down per account but does not change its nature. SRP improves on this because the thing stored, the verifier, is never transmitted during a login; an attacker needs to steal the database itself, not just eavesdrop. But once stolen, the verifier is exactly as attackable offline as a salted hash, because it was computed the same way: H(salt, password) fed into a public, fixed formula.
OPAQUE’s registration record is different in kind. It is still something the server stores and the client never has to send again. But it was produced using an oblivious pseudorandom function, which means the server helped compute a value derived from the password at registration time, without ever learning the password or even the final value. What ends up in the server’s database is protected by a key that only the password can recover. An attacker who steals the records alone cannot test a single password guess against them. An attacker who also steals the server’s OPRF key can, but must repeat the server’s own OPRF evaluation for every guess. That work is tied to one server’s secret, so no table of guesses can be computed in advance.
The Oblivious Pseudorandom Function
An OPRF is a two-party protocol for evaluating a pseudorandom function F(key, input). The server holds the key, and the client holds the input. After the exchange, the client learns F(key, input), while the server learns nothing about the input or the output. RFC 9497 builds one from any prime-order group with a hash-to-group function.
The P256-SHA256 version works as follows:
- Blind. The client hashes its password to a curve point with
HashToGroup, then multiplies it by a fresh random scalarblind. This blinded point reveals nothing about the password: any point on the curve could be the result of blinding any input. - BlindEvaluate. The server multiplies the blinded point by its own private scalar, the OPRF key. It learns nothing from the point it receives, because the random blind makes that point independent of the password.
- Finalize. The client multiplies the result by the inverse of its own blind, which removes the blinding and leaves
oprf_key * HashToGroup(password). It hashes this together with the password itself to get the final output.
The HashToGroup function is hash_to_curve from RFC 9380, with the suite P256_XMD:SHA-256_SSWU_RO_. It expands the message with expand_message_xmd (built on SHA-256), interprets the expansion as two field elements, maps each to a curve point with the simplified Shallue-van de Woestijne-Ulas method, and adds the two points together. A single mapped point is not uniform: it reaches only a fraction of the curve, and some points more often than others. Adding two of them gives a uniformly random point, which is what a protocol proven in the random oracle model needs.
Registration
Registration runs the OPRF once, with the client’s password as the OPRF input, and then uses the result to build a credential record.
- The client calls
Blind(password)and sends the blinded element. - The server derives a per-account OPRF key from a server-wide seed and the account’s
credential_identifier, evaluates the OPRF on the blinded element, and returns the result along with its own long-term public key. - The client finalizes the OPRF to get
oprf_output, then stretches it with a slow key-stretching function. Argon2id is what the RFC recommends; its own test vectors use the identity function to keep them reproducible, and this guide follows the vectors. A real deployment must replace the identity function with Argon2id or a similar slow function. Without it, an attacker who steals the OPRF key pays almost nothing per guess. The client then extracts arandomized_passwordfrom both values with HKDF. - From
randomized_password, the client derives a fresh AKE key pair, deterministically, so that logging in later recovers the same pair. It also derives a masking key and an export key, and builds an envelope that the server will store and hand back at every login. - The record the server keeps is the client’s new public key, the masking key, and the envelope. None of it is the password, the OPRF output, or anything an attacker could feed into an offline test without repeating the OPRF evaluation for that specific
oprf_key.
The Envelope
The envelope is 64 bytes: a 32-byte nonce and a 32-byte authentication tag. It does not encrypt anything. The client’s AKE private key is never stored anywhere; it is recomputed from randomized_password and the envelope’s nonce every time, using the same HKDF expansion as at registration. The tag authenticates the nonce together with the server’s public key and both parties’ identities. A login can then detect whether the recovered randomized_password, and therefore the derived key pair, is right. It can also detect whether the server’s public key matches the one used at registration.
This design means the envelope’s only job is a check, not a container. If a client tries to log in with the wrong password, it recomputes a different randomized_password, different keys, and a different expected tag, and the tag comparison fails cleanly with no key material leaked either way.
Login: Credential Retrieval and 3DH
A login runs the same OPRF exchange again, now called credential retrieval, and layers a three-message Diffie-Hellman handshake, 3DH, on top of it.
- KE1 bundles the OPRF blinded element with a fresh ephemeral public key and a nonce.
- KE2 returns the OPRF evaluation and the server’s public key and envelope, masked with a pad derived from the record’s masking key and a fresh nonce. It adds the server’s own fresh ephemeral key and a MAC over the transcript so far.
- KE3 carries the client’s own MAC, after the client has recovered its private key from the envelope, verified the server’s MAC, and computed its half of the handshake.
The handshake takes three Diffie-Hellman results as shared secret material: one between the two ephemeral keys, one between the client’s ephemeral key and the server’s long-term key, and one between the client’s long-term key and the server’s ephemeral key. This triple-DH structure is why the construction is called 3DH; it is the same idea behind the Signal protocol’s X3DH. All three results, plus a transcript hash of everything sent so far, are mixed through HKDF with TLS 1.3 style labeled expansion. This gives the session key and a handshake secret, and the two MAC keys come from the handshake secret.
The client’s long-term private key never leaves the client, and the server’s long-term private key never leaves the server. An eavesdropper who later steals one party’s long-term key still cannot decrypt a session recorded earlier, because the ephemeral keys that also went into the session key are gone. This is forward secrecy, and it comes from the handshake, not from the password check.
Client Enumeration
A server that answers “no such account” differently from “wrong password” leaks which accounts exist, letting an attacker enumerate valid usernames before even trying passwords. OPAQUE’s credential retrieval step is built to avoid this. When an account does not exist, the RFC tells the server to build a fake record using the exact recipe a real one would use: a random public key, a random masking key, a zeroed envelope. It then runs the normal credential-response function on that fake record. The size and shape of the response the client gets back is identical whether or not the account is real; only a client that actually knows the password can finish the handshake either way.
The RFC recommends creating the fake record once and storing it alongside the real ones. The OPRF key is already derived from the server’s seed and the account identifier. So the same unknown name always gets a consistent response, fetched in about the time a real record takes. The defense does not cover registration, where a server necessarily reacts differently to a name that is already taken. The RFC suggests rate limiting that flow instead.
Interactive Visualizer
The visualizer above runs a full registration followed by a login, using P256-SHA256.
- Seed. A word that stands in for the server’s static key, its OPRF seed, and every nonce and ephemeral key in the run. It is not part of the protocol; it exists only so the run can be reproduced and checked against the page.
- Password. What the client registers and then logs in with.
- Client identity and server identity. Optional application-level identities; leave either empty to let it default to that party’s public key, as the RFC specifies.
- Damage. Log in with a different password than was registered, tamper with KE2 or KE3 in transit, or log in against a record that was never produced by the registration shown, and watch which step catches it.
The log shows the size of every message exchanged and the final outcome: the shared session key, or the point and reason the handshake stopped.
A Worked Example
Using the password CorrectHorseBatteryStaple, the first official test vector of RFC 9807 appendix C.1, section C.1.5. The server’s public key is:
035f40ff9cf88aa1f5cd4fe5fd3da9ea65a4923a5594f84fd9f2092d6067784874
The registration request, which is only a blinded curve point, is 33 bytes. The server’s response, the evaluated point plus its own public key, is 66 bytes. After the client finalizes the OPRF, the randomized password is:
06be0a1a51d56557a3adad57ba29c5510565dcd8b5078fa319151b9382258fb0
From this, the client derives its new public key:
03b218507d978c3db570ca994aaf36695a731ddb2db272c817f79746fc37ae5214
and an envelope of 64 bytes, protected by an authentication key:
5bd4be1602516092dc5078f8d699f5721dc1720a49fb80d8e5c16377abd0987b
The full registration record that the server stores is 129 bytes: the public key, a masking key, and the envelope. At login, KE1 is 98 bytes, KE2 is 259 bytes (the masked envelope plus the server’s own ephemeral material and MAC), and KE3 is 32 bytes, just the client’s MAC. After the handshake, both sides hold the session key:
484ad345715ccce138ca49e4ea362c6183f0949aaaa1125dc3bc3f80876e7cd1
and the client additionally holds an export key, independent of the session key, for the application’s own use:
c3c9a1b0e33ac84dd83d0b7e8af6794e17e7a3caadff289fbd9dc769a853c64b
Python Implementation
The implementation has three files. The first is NIST P-256 point arithmetic and the hash-to-curve suite of RFC 9380.
# p256.py
# NIST P-256 (secp256r1) elliptic curve point arithmetic in affine coordinates, and the hash-to-curve suite
# P256_XMD:SHA-256_SSWU_RO_ of RFC 9380 (sections 3, 5, 6.6.2).
# Teaching code. It is not constant time: the inverse uses Fermat's little theorem but the point arithmetic
# branches on whether a point is the identity, and is_square branches on its result.
import hashlib
P = 2 ** 256 - 2 ** 224 + 2 ** 192 + 2 ** 96 - 1
A = -3 % P
B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296
GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5
IDENTITY = None # the point at infinity, affine (x, y) otherwise
def inv(x, modulus=P):
return pow(x, modulus - 2, modulus)
def is_on_curve(point):
if point is IDENTITY:
return True
x, y = point
return (y * y - (x * x * x + A * x + B)) % P == 0
def add(p1, p2):
if p1 is IDENTITY:
return p2
if p2 is IDENTITY:
return p1
x1, y1 = p1
x2, y2 = p2
if x1 == x2:
if (y1 + y2) % P == 0:
return IDENTITY
lam = (3 * x1 * x1 + A) * inv(2 * y1) % P
else:
lam = (y2 - y1) * inv(x2 - x1) % P
x3 = (lam * lam - x1 - x2) % P
y3 = (lam * (x1 - x3) - y1) % P
return (x3, y3)
def scalar_mult(k, point):
k %= N
result = IDENTITY
addend = point
while k:
if k & 1:
result = add(result, addend)
addend = add(addend, addend)
k >>= 1
return result
def scalar_mult_gen(k):
return scalar_mult(k, (GX, GY))
# ---------------------------------------------------------------- SEC1 compressed point encoding (Ne = 33)
def serialize_element(point):
if point is IDENTITY:
return bytes(33) # not a valid SEC1 encoding on its own, used only for the fake-record tests
x, y = point
return bytes([0x02 | (y & 1)]) + x.to_bytes(32, "big")
def deserialize_element(data):
assert len(data) == 33 and data[0] in (0x02, 0x03)
x = int.from_bytes(data[1:], "big")
assert 0 <= x < P
rhs = (x * x * x + A * x + B) % P
y = pow(rhs, (P + 1) // 4, P) # P mod 4 == 3, so this is the square root when one exists
assert (y * y) % P == rhs, "not a point on the curve"
if (y & 1) != (data[0] & 1):
y = P - y
return (x, y)
def serialize_scalar(k):
return (k % N).to_bytes(32, "big")
def deserialize_scalar(data):
k = int.from_bytes(data, "big")
assert 0 <= k < N
return k
# ---------------------------------------------------------------- RFC 9380: expand_message_xmd and hash_to_field
def i2osp(n, length):
return n.to_bytes(length, "big")
def expand_message_xmd(msg, dst, len_in_bytes, hash_name="sha256", b_in_bytes=32, s_in_bytes=64):
if len(dst) > 255:
dst = b"H2C-OVERSIZE-DST-" + hashlib.new(hash_name, dst).digest()
dst_prime = dst + i2osp(len(dst), 1)
ell = -(-len_in_bytes // b_in_bytes)
assert ell <= 255 and len_in_bytes <= 65535
z_pad = bytes(s_in_bytes)
msg_prime = z_pad + msg + i2osp(len_in_bytes, 2) + i2osp(0, 1) + dst_prime
b0 = hashlib.new(hash_name, msg_prime).digest()
b1 = hashlib.new(hash_name, b0 + i2osp(1, 1) + dst_prime).digest()
blocks = [b1]
for i in range(2, ell + 1):
xored = bytes(a ^ b for a, b in zip(b0, blocks[-1]))
blocks.append(hashlib.new(hash_name, xored + i2osp(i, 1) + dst_prime).digest())
return b"".join(blocks)[:len_in_bytes]
def hash_to_field(msg, count, dst, modulus=P, l=48):
uniform_bytes = expand_message_xmd(msg, dst, count * l)
return [int.from_bytes(uniform_bytes[l * i:l * (i + 1)], "big") % modulus for i in range(count)]
# ---------------------------------------------------------------- RFC 9380 section 6.6.2: simplified SWU, for P-256 (A, B != 0)
Z = -10 % P
def sgn0(x):
return x & 1
def is_square(x):
return x == 0 or pow(x, (P - 1) // 2, P) == 1
def sqrt_mod_p(x):
return pow(x, (P + 1) // 4, P) # valid because P % 4 == 3
def inv0(x):
return 0 if x % P == 0 else inv(x)
def map_to_curve_simple_swu(u):
tv1 = inv0((Z * Z % P * pow(u, 4, P) + Z * u * u) % P)
x1 = (-B % P) * inv(A) % P * (1 + tv1) % P
if tv1 == 0:
x1 = B * inv(Z * A % P) % P
gx1 = (x1 * x1 * x1 + A * x1 + B) % P
x2 = Z * u * u % P * x1 % P
gx2 = (x2 * x2 * x2 + A * x2 + B) % P
if is_square(gx1):
x, y = x1, sqrt_mod_p(gx1)
else:
x, y = x2, sqrt_mod_p(gx2)
if sgn0(u) != sgn0(y):
y = (-y) % P
return (x, y)
def hash_to_curve(msg, dst):
u0, u1 = hash_to_field(msg, 2, dst)
q0 = map_to_curve_simple_swu(u0)
q1 = map_to_curve_simple_swu(u1)
r = add(q0, q1)
return r # h_eff = 1 for P-256: no cofactor clearing needed
The second is the OPRF of RFC 9497.
# oprf_p256.py
# The OPRF(P-256, SHA-256) ciphersuite of RFC 9497, base mode (mode 0x00): Blind, BlindEvaluate, Finalize,
# Evaluate, and the deterministic DeriveKeyPair. This is the two-party pseudorandom function that OPAQUE builds on.
# Teaching code. It is not constant time, and the blind comes from the caller so that the vectors can be tested.
import hashlib
import p256 as c
MODE_OPRF = 0x00
SUITE_ID = "P256-SHA256"
def i2osp(n, length):
return n.to_bytes(length, "big")
def context_string(mode, suite_id=SUITE_ID):
return b"OPRFV1-" + i2osp(mode, 1) + b"-" + suite_id.encode()
def hash_to_group(data, mode=MODE_OPRF):
return c.hash_to_curve(data, b"HashToGroup-" + context_string(mode))
def hash_to_scalar(data, dst):
return c.hash_to_field(data, 1, dst, modulus=c.N, l=48)[0]
def derive_key_pair(seed, info, mode=MODE_OPRF):
derive_input = seed + i2osp(len(info), 2) + info
dst = b"DeriveKeyPair" + context_string(mode)
sk = 0
counter = 0
while sk == 0:
if counter > 255:
raise ValueError("DeriveKeyPairError")
sk = hash_to_scalar(derive_input + i2osp(counter, 1), dst)
counter += 1
return sk, c.scalar_mult_gen(sk)
# ---------------------------------------------------------------- the client side
def blind(input_bytes, blind_scalar):
element = hash_to_group(input_bytes)
assert element is not None, "the input hashed to the group identity"
blinded_element = c.scalar_mult(blind_scalar, element)
return blinded_element
def finalize(input_bytes, blind_scalar, evaluated_element):
n = c.scalar_mult(c.inv(blind_scalar, c.N), evaluated_element)
unblinded = c.serialize_element(n)
hash_input = i2osp(len(input_bytes), 2) + input_bytes + i2osp(len(unblinded), 2) + unblinded + b"Finalize"
return hashlib.sha256(hash_input).digest()
# ---------------------------------------------------------------- the server side
def blind_evaluate(sk, blinded_element):
return c.scalar_mult(sk, blinded_element)
def evaluate(sk, input_bytes):
# the same output as a full Blind/BlindEvaluate/Finalize round trip, computable directly by whoever holds sk
element = hash_to_group(input_bytes)
assert element is not None
evaluated = c.scalar_mult(sk, element)
issued = c.serialize_element(evaluated)
hash_input = i2osp(len(input_bytes), 2) + input_bytes + i2osp(len(issued), 2) + issued + b"Finalize"
return hashlib.sha256(hash_input).digest()
The third is OPAQUE-3DH itself: the envelope, registration, credential retrieval, and the 3DH handshake with its key schedule.
# opaque.py
# OPAQUE-3DH (RFC 9807) with the P256-SHA256 configuration: OPRF(P-256, SHA-256) of oprf_p256.py, HKDF-SHA256,
# HMAC-SHA256, SHA-256, and the identity key-stretching function (the RFC's own test vectors use it so that they
# do not depend on a slow KSF; a real deployment MUST use a real one, such as Argon2id, which is why the article
# does not recommend running this code as written for a real login).
# Teaching code. It is not constant time, and every random value (nonces, blinds, seeds) comes from the caller.
import hashlib
import hmac as _hmac
import p256 as c
import oprf_p256 as oprf
NN, NSEED, NH, NPK, NSK, NM, NX, NOE, NOK = 32, 32, 32, 33, 32, 32, 32, 33, 32
CONTEXT = b"OPAQUE-POC"
def i2osp(n, length):
return n.to_bytes(length, "big")
def xor(a, b):
return bytes(x ^ y for x, y in zip(a, b))
# ---------------------------------------------------------------- HKDF-SHA256 and HMAC-SHA256
def hmac_sha256(key, data):
return _hmac.new(key, data, hashlib.sha256).digest()
def extract(salt, ikm):
return hmac_sha256(salt, ikm) if salt else hmac_sha256(bytes(NX), ikm)
def expand(prk, info, length):
out, previous, counter = b"", b"", 0
while len(out) < length:
counter += 1
previous = hmac_sha256(prk, previous + info + bytes([counter]))
out += previous
return out[:length]
def mac(key, msg):
return hmac_sha256(key, msg)
def hash_(msg):
return hashlib.sha256(msg).digest()
def stretch(msg): # the identity KSF, as the RFC's own test vectors use
return msg
# ---------------------------------------------------------------- the TLS 1.3 style transcript labels
def expand_label(secret, label, context, length):
custom_label = i2osp(length, 2) + i2osp(len(b"OPAQUE-" + label), 1) + b"OPAQUE-" + label + i2osp(len(context), 1) + context
return expand(secret, custom_label, length)
def derive_secret(secret, label, transcript_hash):
return expand_label(secret, label, transcript_hash, NX)
# ---------------------------------------------------------------- 3DH over P-256 (section 6.4.1.2, referring to 6.4.1.1)
def derive_diffie_hellman_key_pair(seed):
sk, pk = oprf.derive_key_pair(seed, b"OPAQUE-DeriveDiffieHellmanKeyPair")
return sk, c.serialize_element(pk)
def diffie_hellman(k, serialized_point):
point = c.deserialize_element(serialized_point)
return c.serialize_element(c.scalar_mult(k, point))
# ---------------------------------------------------------------- cleartext credentials (section 4)
def create_cleartext_credentials(server_public_key, client_public_key, server_identity, client_identity):
server_identity = server_identity if server_identity is not None else server_public_key
client_identity = client_identity if client_identity is not None else client_public_key
return {"server_public_key": server_public_key, "server_identity": server_identity, "client_identity": client_identity}
def serialize_cleartext_credentials(cc):
return cc["server_public_key"] + i2osp(len(cc["server_identity"]), 2) + cc["server_identity"] + i2osp(len(cc["client_identity"]), 2) + cc["client_identity"]
# ---------------------------------------------------------------- the envelope (section 4.1)
def store(randomized_password, server_public_key, server_identity, client_identity, envelope_nonce):
masking_key = expand(randomized_password, b"MaskingKey", NH)
auth_key = expand(randomized_password, envelope_nonce + b"AuthKey", NH)
export_key = expand(randomized_password, envelope_nonce + b"ExportKey", NH)
seed = expand(randomized_password, envelope_nonce + b"PrivateKey", NSEED)
_, client_public_key = derive_diffie_hellman_key_pair(seed)
cc = create_cleartext_credentials(server_public_key, client_public_key, server_identity, client_identity)
auth_tag = mac(auth_key, envelope_nonce + server_public_key +
i2osp(len(cc["server_identity"]), 2) + cc["server_identity"] +
i2osp(len(cc["client_identity"]), 2) + cc["client_identity"])
envelope = envelope_nonce + auth_tag
return envelope, client_public_key, masking_key, export_key
def recover(randomized_password, server_public_key, envelope, server_identity, client_identity):
envelope_nonce, auth_tag = envelope[:NN], envelope[NN:]
auth_key = expand(randomized_password, envelope_nonce + b"AuthKey", NH)
export_key = expand(randomized_password, envelope_nonce + b"ExportKey", NH)
seed = expand(randomized_password, envelope_nonce + b"PrivateKey", NSEED)
client_private_key, client_public_key = derive_diffie_hellman_key_pair(seed)
cc = create_cleartext_credentials(server_public_key, client_public_key, server_identity, client_identity)
expected_tag = mac(auth_key, envelope_nonce + serialize_cleartext_credentials(cc))
if not _hmac.compare_digest(auth_tag, expected_tag):
raise ValueError("EnvelopeRecoveryError")
return client_private_key, cc, export_key
# ---------------------------------------------------------------- registration (section 5)
def create_registration_request(password, blind):
blinded_element = oprf.blind(password, blind)
return c.serialize_element(blinded_element) # the RegistrationRequest
def create_registration_response(request, server_public_key, credential_identifier, oprf_seed):
seed = expand(oprf_seed, credential_identifier + b"OprfKey", NOK)
oprf_key, _ = oprf.derive_key_pair(seed, b"OPAQUE-DeriveKeyPair")
blinded_element = c.deserialize_element(request)
evaluated_element = oprf.blind_evaluate(oprf_key, blinded_element)
return c.serialize_element(evaluated_element) + server_public_key # the RegistrationResponse
def finalize_registration_request(password, blind, response, server_identity, client_identity, envelope_nonce):
evaluated_message, server_public_key = response[:NOE], response[NOE:]
evaluated_element = c.deserialize_element(evaluated_message)
oprf_output = oprf.finalize(password, blind, evaluated_element)
randomized_password = extract(b"", oprf_output + stretch(oprf_output))
envelope, client_public_key, masking_key, export_key = store(randomized_password, server_public_key, server_identity, client_identity, envelope_nonce)
record = client_public_key + masking_key + envelope # the RegistrationRecord
return record, export_key
# ---------------------------------------------------------------- credential retrieval (section 6.3)
def create_credential_request(password, blind):
return create_registration_request(password, blind) # the same construction
def create_credential_response(request, server_public_key, record, credential_identifier, oprf_seed, masking_nonce):
seed = expand(oprf_seed, credential_identifier + b"OprfKey", NOK)
oprf_key, _ = oprf.derive_key_pair(seed, b"OPAQUE-DeriveKeyPair")
blinded_element = c.deserialize_element(request)
evaluated_element = oprf.blind_evaluate(oprf_key, blinded_element)
evaluated_message = c.serialize_element(evaluated_element)
masking_key, envelope = record[NPK:NPK + NH], record[NPK + NH:]
pad = expand(masking_key, masking_nonce + b"CredentialResponsePad", NPK + NN + NM)
masked_response = xor(pad, server_public_key + envelope)
return evaluated_message + masking_nonce + masked_response # the CredentialResponse
def recover_credentials(password, blind, response, server_identity, client_identity):
evaluated_message, masking_nonce, masked_response = response[:NOE], response[NOE:NOE + NN], response[NOE + NN:]
evaluated_element = c.deserialize_element(evaluated_message)
oprf_output = oprf.finalize(password, blind, evaluated_element)
randomized_password = extract(b"", oprf_output + stretch(oprf_output))
masking_key = expand(randomized_password, b"MaskingKey", NH)
pad = expand(masking_key, masking_nonce + b"CredentialResponsePad", NPK + NN + NM)
plain = xor(pad, masked_response)
server_public_key, envelope = plain[:NPK], plain[NPK:]
return recover(randomized_password, server_public_key, envelope, server_identity, client_identity)
# ---------------------------------------------------------------- the 3DH key schedule (section 6.4.2)
def preamble(client_identity, ke1, server_identity, credential_response, server_nonce, server_public_keyshare, context=CONTEXT):
return (b"OPAQUEv1-" + i2osp(len(context), 2) + context +
i2osp(len(client_identity), 2) + client_identity + ke1 +
i2osp(len(server_identity), 2) + server_identity + credential_response +
server_nonce + server_public_keyshare)
def derive_keys(ikm, preamble_bytes):
prk = extract(b"", ikm)
transcript_hash = hash_(preamble_bytes)
handshake_secret = derive_secret(prk, b"HandshakeSecret", transcript_hash)
session_key = derive_secret(prk, b"SessionKey", transcript_hash)
km2 = derive_secret(handshake_secret, b"ServerMAC", b"")
km3 = derive_secret(handshake_secret, b"ClientMAC", b"")
return km2, km3, session_key
# ---------------------------------------------------------------- the full AKE (sections 6.2 and 6.4.3/6.4.4)
def auth_client_start(credential_request, client_nonce, client_keyshare_seed):
client_secret, client_public_keyshare = derive_diffie_hellman_key_pair(client_keyshare_seed)
auth_request = client_nonce + client_public_keyshare
ke1 = credential_request + auth_request
return ke1, client_secret
def auth_server_respond(cc, server_private_key, client_public_key, ke1, credential_response, server_nonce, server_keyshare_seed, context=CONTEXT):
server_private_keyshare, server_public_keyshare = derive_diffie_hellman_key_pair(server_keyshare_seed)
client_public_keyshare = ke1[NOE + NN:NOE + NN + NPK]
pre = preamble(cc["client_identity"], ke1, cc["server_identity"], credential_response, server_nonce, server_public_keyshare, context)
dh1 = diffie_hellman(server_private_keyshare, client_public_keyshare)
dh2 = diffie_hellman(server_private_key, client_public_keyshare)
dh3 = diffie_hellman(server_private_keyshare, client_public_key)
km2, km3, session_key = derive_keys(dh1 + dh2 + dh3, pre)
server_mac = mac(km2, hash_(pre))
expected_client_mac = mac(km3, hash_(pre + server_mac))
auth_response = server_nonce + server_public_keyshare + server_mac
return auth_response, expected_client_mac, session_key
def generate_ke2(server_identity, server_private_key, server_public_key, record, credential_identifier, oprf_seed,
ke1, client_identity, masking_nonce, server_nonce, server_keyshare_seed, context=CONTEXT):
credential_request = ke1[:NOE]
client_public_key = record[:NPK]
credential_response = create_credential_response(credential_request, server_public_key, record, credential_identifier, oprf_seed, masking_nonce)
cc = create_cleartext_credentials(server_public_key, client_public_key, server_identity, client_identity)
auth_response, expected_client_mac, session_key = auth_server_respond(cc, server_private_key, client_public_key, ke1, credential_response, server_nonce, server_keyshare_seed, context)
ke2 = credential_response + auth_response
return ke2, expected_client_mac, session_key
def auth_client_finalize(cc, client_private_key, ke1, client_secret, ke2, context=CONTEXT):
credential_response_len = NOE + NN + (NPK + NN + NM)
credential_response = ke2[:credential_response_len]
auth_response = ke2[credential_response_len:]
server_nonce, server_public_keyshare, server_mac = auth_response[:NN], auth_response[NN:NN + NPK], auth_response[NN + NPK:]
dh1 = diffie_hellman(client_secret, server_public_keyshare)
dh2 = diffie_hellman(client_secret, cc["server_public_key"])
dh3 = diffie_hellman(client_private_key, server_public_keyshare)
pre = preamble(cc["client_identity"], ke1, cc["server_identity"], credential_response, server_nonce, server_public_keyshare, context)
km2, km3, session_key = derive_keys(dh1 + dh2 + dh3, pre)
expected_server_mac = mac(km2, hash_(pre))
if not _hmac.compare_digest(server_mac, expected_server_mac):
raise ValueError("ServerAuthenticationError")
client_mac = mac(km3, hash_(pre + expected_server_mac))
return client_mac, session_key # client_mac is KE3
def generate_ke1(password, blind, client_nonce, client_keyshare_seed):
credential_request = create_credential_request(password, blind)
ke1, client_secret = auth_client_start(credential_request, client_nonce, client_keyshare_seed)
return ke1, client_secret
def generate_ke3(password, blind, client_identity, server_identity, ke1, client_secret, ke2, context=CONTEXT):
client_private_key, cc, export_key = recover_credentials(password, blind, ke2[:NOE + NN + NPK + NN + NM], server_identity, client_identity)
ke3, session_key = auth_client_finalize(cc, client_private_key, ke1, client_secret, ke2, context)
return ke3, session_key, export_key
def server_finish(ke3, expected_client_mac):
if not _hmac.compare_digest(ke3, expected_client_mac):
raise ValueError("ClientAuthenticationError")
return True
I checked this code in layers.
- The curve. The base point has order
0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551over the prime field0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff, point addition agrees with scalar multiplication, Diffie-Hellman commutes, and 200 random points round-trip through the compressed SEC1 encoding used on the wire. expand_message_xmd. All 10 vectors of RFC 9380 appendix K.1, for both a short and a long output length.- Hashing to the curve. All 5 message vectors of RFC 9380 appendix J.1.1 for the suite
P256_XMD:SHA-256_SSWU_RO_. Each is checked both for the final point and for the two intermediate points the construction adds together, usingZ = 115792089210356248762697446949407573530086143415290314195533631308867097853941, the value the RFC recommends for this curve. - The OPRF. Both official vectors of RFC 9497 appendix A.3.1, including the deterministic key derivation for the server’s private key. 30 random (key, message, blind) triples confirm that the two-message blinded protocol agrees with the direct, unblinded evaluation of the same function.
- OPAQUE-3DH. Both real test vectors of RFC 9807 appendix C.1 that use P-256 are covered: test vectors 5 and 6, one without and one with application identities. Every output value, from the registration request through the session key and export key, matches byte for byte. 15 random full registrations and logins, varying the identities, agree between client and server on the session key and the export key. A wrong password is rejected exactly at envelope recovery, before any key material is used. A damaged
KE2is rejected by the client, and a damagedKE3is rejected by the server. ACredentialResponsebuilt from a fake record, following the recipe for defending against client enumeration, has exactly the same length as one built from a real record.
The page code ports all three layers to JavaScript: its own SHA-256, HKDF, P-256 point arithmetic, expand_message_xmd, and hash_to_curve. These match the same RFC 9380 vectors and Node’s crypto module. The OPRF and OPAQUE-3DH logic match both real RFC 9807 vectors byte for byte. The full registration-and-login flow the page runs, driven by a deterministic stand-in for randomness, reproduces every message of 8 scenarios byte for byte against the same flow run in Python, including the four kinds of damage the visualizer offers. I ran the page in a simulated browser.
Where It Stands
- A 2025 RFC, already implemented. Several open-source libraries shipped OPAQUE implementations before the RFC was finalized, tracking the draft; the final RFC mostly stabilized wire formats and test vectors.
- Aimed at replacing password hashing at the server, not TLS. OPAQUE assumes a secure channel is not available or not trusted for the password itself. It is a defense specifically against a server that is later compromised, or a server an attacker tricks the client into talking to.
- Built on young building blocks. The OPRF of RFC 9497 and the hash-to-curve construction of RFC 9380 are both recent enough that this guide’s independent verification against their published test vectors is doing real work, not just restating settled wisdom.
- A close relative of SRP. Both are augmented PAKEs with the same basic shape, a stored record and a handshake; OPAQUE’s registration record is harder to attack offline because of the OPRF, which is the main improvement the newer design makes.
Limitations
This is a teaching implementation, not a production one:
- The identity key-stretching function. This code uses
Stretch(msg) = msg, which is what the RFC’s own test vectors use so that they are reproducible. A real deployment must replace this with a slow function such as Argon2id; without one, the stretched value adds no cost to an offline attacker who has broken the OPRF key. - One configuration. Only
P256-SHA256is implemented. The RFC also recommendsristretto255-SHA512, which needs different point arithmetic entirely. - No real randomness in the page. Every nonce, blind, and ephemeral key comes from a seed word, so a run can be reproduced and checked; a real implementation needs a cryptographically secure random source for all of them.
- No server storage, no rate limiting, and no account lockout. The code builds and reads the protocol messages; it does not implement a database, a fake-record cache for client enumeration defense, or any of the operational protections section 9 of the RFC describes.
- Not constant time. The point arithmetic branches on whether a value is the group identity, and modular exponentiation is the ordinary square-and-multiply loop.
Security Status
RFC 9807’s security considerations, and the proof the RFC cites, converge on a few points.
- The core security property is proven in the random oracle model. The 2018 paper by Jarecki, Krawczyk, and Xu proves OPAQUE secure in a strong aPAKE model that rules out precomputation attacks. The RFC notes that the proof covers one server key, and that its own design differs in small ways from the paper. It shows that an attacker who does not know the password learns only one thing from any number of login attempts: whether each individual guess was right. That is the best any password protocol can offer. The guessing itself, not any leaked structure, is the attacker’s only tool.
- The server’s stored record does not reduce to a plain hash. Attacking it offline requires repeating the server’s own OPRF evaluation for each password guess, tied to that server’s specific OPRF key. A stolen database alone does not immediately hand over passwords the way a stolen table of salted hashes does.
- Forward secrecy depends on the ephemeral keys, not the password. Each of the three Diffie-Hellman results that go into the session key involves at least one ephemeral key. So recording a session and later learning the long-term keys of both parties does not decrypt it.
- The identities matter for what gets authenticated. If an application leaves
client_identityorserver_identityunset, each defaults to the corresponding public key. This binds the session to that specific key pair, not to any human-meaningful name. An application that needs to bind a session to an account name, not just a key, must set the identity explicitly. - Key stretching is not optional in practice. RFC 9807 section 10.8 is explicit that the choice of KSF and its parameters determine how expensive an offline attack against a stolen record actually is. The identity function used for reproducible test vectors gives no protection at all, as this guide’s limitations section already states.
FAQ
Does the server ever see the password?
No, not in any form, not even once, not even during registration. The server only ever sees a blinded elliptic curve point and, later, a record whose key pair and envelope only the password can reproduce.
What happens if the server’s database is stolen?
The attacker gets each account’s public key, masking key, and envelope, but not the password. Testing a guessed password against a stolen record requires repeating the OPRF evaluation using the server’s own per-account key, which the attacker does not have unless they also stole the OPRF seed.
Why does OPAQUE need a key-stretching function at all, if the OPRF already protects the password?
The OPRF protects the password from a passive observer of the network and from a server that behaves honestly during registration. The key-stretching function is the last line of defense if the OPRF key itself is ever compromised, which would otherwise reduce the attack to a plain hash.
How is this different from sending a password over TLS to a server that hashes it?
TLS protects the password only in transit. OPAQUE protects it from the server itself: a server that is later compromised, logs more than it should, or is not really the server the client thinks it is talking to, never had the password to begin with.
Can two different services use the same password with OPAQUE?
The protocol does not stop a user from reusing a password, and nothing in OPAQUE changes the advice to use different passwords for different services. What it changes is what a single compromised server can do with the password it stored, which is far less than with a traditional hash.
Is OPAQUE a replacement for TLS?
No, OPAQUE is not a replacement for TLS. It authenticates a password and derives a session key, but it says nothing about certificates or network routing. In practice, OPAQUE is typically run inside a TLS connection as well.
References
-
RFC 9807, “The OPAQUE Augmented Password-Authenticated Key Exchange (aPAKE) Protocol,” by Bourdrez, Krawczyk, Lewi, and Wood, July 2025. Available at: https://www.rfc-editor.org/rfc/rfc9807
-
RFC 9497, “Oblivious Pseudorandom Functions (OPRFs) Using Prime-Order Groups,” by Davidson, Faz-Hernandez, Sullivan, and Wood, December 2023. Available at: https://www.rfc-editor.org/rfc/rfc9497
-
RFC 9380, “Hashing to Elliptic Curves,” by Faz-Hernandez, Scott, Sullivan, Wahby, and Wood, August 2023. Available at: https://www.rfc-editor.org/rfc/rfc9380
-
Jarecki, Krawczyk, and Xu. “OPAQUE: An Asymmetric PAKE Protocol Secure Against Pre-Computation Attacks,” Eurocrypt 2018, IACR ePrint 2018/163, the original paper. Available at: https://eprint.iacr.org/2018/163