Two devices need to talk. Neither one trusts the other. There is no certificate authority in the loop. There is no pre-shared key burned into firmware. There is a human who typed a short code into both devices, and that code is the only shared secret you have to work with.

This is the pairing problem, and it shows up in more places than most engineers realize. Bluetooth device pairing. Peer-to-peer file transfer bootstrapping. Desktop-to-mobile handoff in messaging apps. IoT commissioning. Local network service discovery where mDNS gives you a name but not trust. Every one of these scenarios has the same structure: a weak shared secret, two endpoints, no third party, and a need for a strong session key.

The classical answer is Diffie-Hellman. The correct answer, when your shared secret is a short human-entered value, is a password-authenticated key exchange -- a PAKE. Specifically, SPAKE2. This article walks through why, how, and what goes wrong when people skip steps.

The Key Exchange Problem

Before we get to SPAKE2, it is worth being precise about what we need. Two parties, Alice and Bob, want to establish a shared symmetric key over an untrusted channel. An attacker, Eve, can observe all traffic. An active attacker, Mallory, can modify, replay, or inject messages.

The requirements are:

Confidentiality. Eve learns nothing about the session key from observing the exchange.

Authentication. Alice and Bob each have assurance that they are talking to someone who knows the shared password, not to Mallory.

Forward secrecy. If the password is later compromised, previously recorded sessions remain confidential.

Resistance to offline dictionary attack. An attacker who records the exchange cannot take it offline and try every possible password. Each password guess requires an online interaction.

That last requirement is the one that disqualifies naive approaches. If you hash the password into a key and use it directly, anyone who records the ciphertext can try passwords offline until the decryption looks right. If you use the password to seed a Diffie-Hellman exchange without augmentation, an active attacker can mount a man-in-the-middle attack and confirm password guesses against both sides.

Why Plain Diffie-Hellman Is Not Enough

Standard ephemeral Diffie-Hellman gives you forward secrecy and confidentiality against passive observers. It does not give you authentication. Both parties generate ephemeral key pairs, exchange public values, and derive a shared secret. The math is sound. The problem is that neither party knows who they exchanged with.

Mallory can sit in the middle, run DH with Alice using one key pair and DH with Bob using another, and relay traffic between them after decrypting and re-encrypting. Alice thinks she is talking to Bob. Bob thinks he is talking to Alice. Neither is correct.

The standard fix is to authenticate the DH exchange. In TLS, this is done with certificates signed by a CA. In SSH, this is done with host keys that the user verifies on first connection (trust on first use, or TOFU). Both approaches require some pre-existing trust anchor: a CA root certificate, or a known host key.

In the pairing scenario, you have neither. You have a six-digit code on a screen. That is your entire trust anchor. The question is how to turn that into an authenticated key exchange that resists offline attack.

Fig. 01 -- The man-in-the-middle problem with unauthenticated DH
ALICE MALLORY BOB g^a g^m1 g^m2 g^b K1 = g^(a*m1) Alice thinks this is shared with Bob K2 = g^(b*m2) Bob thinks this is shared with Alice Mallory knows both K1 and K2. She decrypts, reads, re-encrypts, and relays.
Without authentication, Diffie-Hellman is trivially defeated by an active attacker. Mallory runs two independent key exchanges and relays traffic between them. Neither endpoint can detect the interposition.

Password-Authenticated Key Exchange

A PAKE protocol binds a password into the key exchange so that only someone who knows the password can complete the protocol successfully. The critical property is that an eavesdropper or active attacker gains no advantage in guessing the password from observing or tampering with the exchange. Each guess still requires a full protocol run with a real counterparty.

This is a stronger property than it sounds. Consider a six-digit numeric code: one million possible values. Without PAKE, an attacker records the exchange and tries all million offline in seconds. With PAKE, the attacker must interact with a real endpoint for each guess. If the endpoint locks out after three failures, the attacker gets three tries. The effective security of a six-digit code goes from trivial to adequate.

Key term: Balanced vs. augmented PAKE In a balanced PAKE, both parties hold the same password (or a symmetric derivative of it). In an augmented PAKE, one party (typically the server) holds a verifier derived from the password, not the password itself. This means a server compromise does not directly reveal the password. SPAKE2 is balanced. SRP and OPAQUE are augmented. The choice depends on your threat model: peer-to-peer pairing uses balanced; client-server login uses augmented.

There are several PAKE protocols in the literature. The three that matter for practitioners are SRP, SPAKE2, and OPAQUE.

SRP (Secure Remote Password) is the oldest widely deployed augmented PAKE. It dates to the late 1990s and is used in Apple's iCloud authentication. SRP has a complicated protocol flow, is tricky to implement correctly, and has no formal security proof in the standard model. It works. It is battle-tested. It is also showing its age.

OPAQUE is a modern augmented PAKE with a full proof in the UC framework. It separates the PAKE from the key derivation cleanly, supports arbitrary password hashing on the server side, and is the direction the IETF is heading for client-server authentication. If you are building a login system from scratch in 2026, OPAQUE is the right answer.

SPAKE2 is a balanced PAKE with an elegant construction, a clean proof, and a small message count. It is specified in RFC 9382. It is the right tool for the pairing scenario: two peers, a shared code, no server/client distinction.

SPAKE2 Protocol Mechanics

SPAKE2 works over any prime-order group. In practice, this means an elliptic curve. The protocol uses two fixed group elements, M and N, whose discrete logarithms relative to the generator are unknown to anyone. This is essential: if anyone knew the discrete log of M or N, they could break the protocol.

Here is the protocol in detail.

Setup. Both parties agree on a group (typically Ristretto255 or P-256), a generator G, and two nothing-up-my-sleeve points M and N. They also share a password w, which is hashed to a scalar in the group's scalar field.

Step 1: Blinded commitments. Alice generates a random scalar x, computes her public share as X = x*G, then blinds it: T = X + w*M. She sends T to Bob. Bob generates a random scalar y, computes Y = y*G, then blinds it: S = Y + w*N. He sends S to Alice.

Step 2: Unblinding and shared secret. Alice receives S, unblinds it by computing S - w*N = Y, then computes the shared secret as x*Y = x*y*G. Bob receives T, unblinds it by computing T - w*M = X, then computes the shared secret as y*X = x*y*G. Both arrive at the same value.

Step 3: Key confirmation. Both parties derive a session key and a confirmation MAC from the shared secret using a KDF. They exchange MACs to prove to each other that they derived the same key. If the MACs do not match, the protocol aborts.

Fig. 02 -- SPAKE2 message flow
ALICE BOB x = random scalar X = x*G T = X + w*M y = random scalar Y = y*G S = Y + w*N T = X + w*M S = Y + w*N Unblind: S - w*N = Y K = x*Y = xyG Unblind: T - w*M = X K = y*X = xyG KDF(K, transcript) -> Ke, MAC confirm_A = MAC(Ke, ...) confirm_B = MAC(Ke, ...) Both sides verify MACs. If they match: authenticated session key established.
The complete SPAKE2 flow. The password w blinds the ephemeral public values so that an attacker cannot extract the DH shares without knowing w. Key confirmation MACs ensure both parties derived the same key. The entire exchange is two messages plus two confirmation messages.

Why M and N Must Have Unknown Discrete Logs

This is the detail that trips people up. The points M and N are not arbitrary. They must be generated in a way that makes it computationally infeasible for anyone to know the discrete logarithm of M with respect to G (i.e., some scalar m such that M = m*G), and likewise for N.

If an attacker knew m, they could compute: T - w*M = X + w*M - w*M = X. But they could also compute T - w'*M for any guess w', and check whether the result is a valid public key that leads to a consistent exchange. This collapses to an offline dictionary attack. The unknown discrete log is what forces the attacker to interact online.

In practice, M and N are generated using a hash-to-curve construction from a fixed, public seed string. RFC 9382 specifies the exact seed strings. The idea is that if you trust the hash function, the discrete logs are unknowable. This is a nothing-up-my-sleeve construction, and it is why you do not pick your own M and N.

Key term: Nothing-up-my-sleeve number A value chosen in a way that is verifiably not rigged. Typically derived from a well-known constant (pi, e, or a descriptive ASCII string) via a standard hash function. The point is to eliminate the possibility that the person who chose the value embedded a trapdoor. In SPAKE2, the M and N points are derived this way. If you use custom M and N without a verifiable derivation, your protocol is broken by assumption.

The Transcript and Why It Matters

The KDF that produces the session key does not just take the shared DH secret as input. It takes the entire protocol transcript: the identities of both parties, the blinded values T and S, and the shared secret. This transcript binding is what prevents a class of attacks where Mallory replays or reorders messages from a previous session.

If you omit the transcript from the KDF input, an attacker who records one successful exchange can replay the blinded values in a new session. With the transcript included, any change in any message changes the derived key, and key confirmation fails.

This is not optional. Implementations that skip transcript hashing are broken. Not theoretically broken. Actually broken, in the sense that they fail to provide the security properties the protocol claims.

Entropy Sourcing

SPAKE2 requires each party to generate a random scalar. The security of the entire protocol depends on the quality of this randomness. If Alice's scalar x is predictable, an attacker can compute X = x*G, subtract it from T, and recover w*M, which gives them the password.

The correct entropy source is your operating system's CSPRNG. On Linux, this is getrandom(2) or /dev/urandom. On macOS, SecRandomCopyBytes. On Windows, BCryptGenRandom. In Rust, rand::rngs::OsRng abstracts this correctly.

Do not seed your own PRNG from time, PID, or any other low-entropy source. Do not use /dev/random on Linux (it blocks unnecessarily on modern kernels and provides no additional security over /dev/urandom once the entropy pool is initialized). Do not use rand::thread_rng() when you need cryptographic randomness -- use OsRng directly or rand::rngs::StdRng seeded from OsRng.

One common mistake in embedded environments is running SPAKE2 before the entropy pool is initialized. On Linux, getrandom(2) blocks until the pool is ready. On bare-metal systems, you may not have that guarantee. If you are running on hardware without a TRNG, you need to understand exactly where your entropy comes from and whether the pool has been seeded before you generate cryptographic scalars.

Entropy in the Password Itself

The password or pairing code is the weakest link in the system. SPAKE2 does not make weak passwords strong. It prevents offline dictionary attacks, which means a six-digit code gives you one million possible values and an attacker must try each one online. That is adequate for a pairing code that is used once and discarded.

It is not adequate for a long-lived password. If the password is reused, and the attacker can attempt one guess per second without lockout, a six-digit code falls in about 12 days on average. A four-digit PIN falls in under an hour.

For pairing scenarios, enforce short code lifetimes. Display the code, use it once, discard it. For anything longer-lived, require higher-entropy passwords or layer SPAKE2 with rate limiting and lockout.

Nonce Discipline and Replay Prevention

SPAKE2 itself is a single-round protocol that does not include nonces in its message format. Replay prevention comes from two sources: the ephemeral randomness (each session uses fresh scalars) and the key confirmation step.

If an attacker replays Alice's blinded value T from a previous session, Bob will generate a fresh y, compute a shared secret using the old X and his new y, and derive a different key than the one from the original session. Alice, using her original x and Bob's new S, will also derive a different key -- but it will not match Bob's. Key confirmation fails. The replay is detected.

This works correctly only if every session uses truly fresh randomness. If the RNG produces the same scalar twice (due to a faulty implementation or a reset-to-factory scenario that restores RNG state), the replay protection disappears. This is another reason why OS CSPRNG quality is non-negotiable.

Session Binding After Key Exchange

Once SPAKE2 completes and you have a session key, you still need to use it correctly. The session key should be used to derive per-direction keys (one for Alice-to-Bob, one for Bob-to-Alice) and per-message nonces using a scheme like HKDF-expand with distinct labels.

Do not reuse the session key directly as an encryption key for multiple messages. Derive subkeys. Use a nonce scheme that guarantees uniqueness -- a simple counter works if both sides agree on who starts at 0 and who starts at 2^63. For AEAD constructions like ChaCha20-Poly1305 or AES-256-GCM, a 96-bit nonce with a 64-bit counter and a 32-bit fixed field (derived from the session) is standard.

Never use random nonces with AES-GCM. The birthday bound on 96-bit random nonces is approximately 2^48 messages before you hit a collision with non-negligible probability. For ChaCha20-Poly1305 with XChaCha's 192-bit nonce, random nonces are safe. For GCM, use a counter.

Integration Patterns

SPAKE2 is a building block, not a complete system. Here are the patterns we have found useful in practice.

Device Pairing Flow

The most common integration: Device A displays a code. The user types it into Device B. Both devices run SPAKE2 with the code as the password. If key confirmation succeeds, the devices exchange long-term public keys over the authenticated channel, then discard the pairing code.

The long-term keys are what matter for subsequent sessions. SPAKE2 bootstraps trust; the long-term keys maintain it. After pairing, future connections use standard authenticated key exchange (e.g., Noise XX with the exchanged public keys) without needing the pairing code again.

This is the pattern used by Magic Wormhole, Signal's device linking, and numerous IoT commissioning protocols. The code is short-lived and low-entropy, but it only needs to be strong enough to authenticate one key exchange.

Fig. 03 -- Pairing flow: SPAKE2 bootstraps long-term trust
PHASE 1: CODE EXCHANGE (OUT OF BAND) Device A displays code 847293. User types it into Device B. This is the shared secret w. PHASE 2: SPAKE2 KEY EXCHANGE Both devices run SPAKE2(w). Two messages + two confirmation MACs. Result: authenticated session key Ke. Code 847293 is discarded. PHASE 3: LONG-TERM KEY EXCHANGE Over the SPAKE2 channel: Device A sends Ed25519 pubkey_A, Device B sends pubkey_B. Both devices store the peer's public key in their trust store. FUTURE SESSIONS: NO CODE NEEDED Noise XX handshake with pubkey_A and pubkey_B. Mutual authentication from stored keys. SPAKE2 is never used again for this pairing. The code is gone.
SPAKE2 is a bootstrap mechanism. It authenticates one session, during which the peers exchange long-term keys. All subsequent sessions use those long-term keys directly. The pairing code is ephemeral by design.

Session Resumption

If you need session resumption (reconnecting without a full handshake), do not re-run SPAKE2. Use the long-term keys established during pairing. SPAKE2 is designed for initial trust establishment, not ongoing session management. Running it repeatedly with the same password increases the attacker's online guessing opportunities.

Multi-Device Groups

SPAKE2 is a two-party protocol. If you need to pair multiple devices into a group, run SPAKE2 pairwise between each new device and one existing group member. The new device receives the group's shared state (encryption keys, membership list) over the authenticated channel. Do not try to extend SPAKE2 to a multi-party setting -- use it as a pairwise bootstrap and handle group key management separately.

Where SPAKE2 Fits in the Landscape

SPAKE2 is not a replacement for TLS. It is not a replacement for WebAuthn. It occupies a specific niche: bootstrapping trust between peers using a short shared secret, with no infrastructure dependencies.

TLS with certificates handles server authentication to clients at scale. It requires a PKI or at least a trusted CA list. If you have that infrastructure, use it.

Mutual TLS (mTLS) handles bidirectional authentication in service meshes and zero-trust architectures. It requires both parties to have certificates issued by a common CA. If you can distribute client certificates, mTLS is more appropriate than SPAKE2.

WebAuthn/FIDO2 handles user authentication to web services using hardware-backed credentials. It requires a browser and a relying party with a registered credential. It is irrelevant to the pairing scenario.

Pre-shared keys (PSK) work when you can securely distribute a high-entropy key to both parties in advance. WireGuard uses PSK as an optional additional authentication layer. If you can distribute 256-bit keys out of band, you do not need SPAKE2 -- just use the PSK directly in a Noise handshake.

SPAKE2 fills the gap where the shared secret is low-entropy (human-entered) and there is no infrastructure to distribute certificates or high-entropy keys. That gap is smaller than it looks, but when you are in it, nothing else works correctly.

Key term: Forward secrecy in SPAKE2 Because SPAKE2 uses ephemeral scalars (x and y are generated fresh each session), it provides forward secrecy. If the password is compromised after the exchange, an attacker who recorded the protocol messages cannot recover the session key without also recovering x or y. This is the same property that ephemeral DH provides in TLS 1.3, and it is why SPAKE2 is preferable to constructions that derive the session key directly from the password.

Implementation Pitfalls

The protocol is simple. The implementation pitfalls are not. Here are the ones that have bitten real systems.

Timing Attacks on Password Comparison

Key confirmation involves comparing a received MAC with a computed MAC. This comparison must be constant-time. If you use a short-circuiting byte comparison (the default == operator in most languages), the comparison time leaks information about how many bytes matched. Over many attempts, this can leak the MAC value, which is equivalent to leaking the session key.

In Rust, use subtle::ConstantTimeEq. In Go, use crypto/subtle.ConstantTimeCompare. In Python, use hmac.compare_digest. Do not roll your own. The compiler may optimize your "constant-time" loop into a short-circuiting comparison.

Missing Key Confirmation

Some implementations skip the key confirmation step, reasoning that if the derived key is wrong, decryption will fail and the parties will notice. This is true for authenticated encryption. It is not true for unauthenticated modes, and it is never true for the first message, which the attacker might craft to look valid.

More importantly, skipping key confirmation turns an authentication failure (wrong password) into an opaque decryption failure later in the session. The user sees "connection failed" instead of "wrong code." From a UX perspective, this is a significant regression. From a security perspective, it opens a window where an active attacker can send one crafted message before the failure is detected.

Always run key confirmation. It costs one MAC computation per side and one round trip. That is negligible.

Weak Password Hashing

The password must be mapped to a scalar in the group's scalar field. The simplest approach is to hash the password with a standard hash function (SHA-256 or BLAKE2b) and reduce modulo the group order. This is fine for short pairing codes where the password space is small and offline attack is already prevented by the protocol.

For longer passwords that you want to protect against server compromise in an augmented setting, use a memory-hard hash like Argon2id before feeding the result into the PAKE. But note that SPAKE2 is balanced, not augmented -- both parties hold the same password. If you need server-side verifier storage, you want OPAQUE, not SPAKE2.

Reusing the Same Code Across Sessions

If the pairing code is displayed once and reused for multiple pairing attempts (e.g., a retry after a typo), each failed attempt gives the attacker one online guess. Display a new code on each attempt, or implement strict rate limiting with exponential backoff on failures. Three consecutive failures should trigger a cooldown period of at least 30 seconds.

The Rust Ecosystem

The spake2 crate (authored by Brian Warner, the creator of Magic Wormhole) is the reference Rust implementation. It uses the Ristretto255 group from the curve25519-dalek crate and implements the full protocol including key confirmation.

Basic usage looks like this:

use spake2::{Ed25519Group, Identity, Password, SPAKE2};

// Alice's side
let (alice_state, alice_msg) = SPAKE2::<Ed25519Group>::start_a(
    &Password::new(b"847293"),
    &Identity::new(b"alice"),
    &Identity::new(b"bob"),
);

// Bob's side
let (bob_state, bob_msg) = SPAKE2::<Ed25519Group>::start_b(
    &Password::new(b"847293"),
    &Identity::new(b"alice"),
    &Identity::new(b"bob"),
);

// Exchange messages, then finish
let alice_key = alice_state.finish(&bob_msg).unwrap();
let bob_key = bob_state.finish(&alice_msg).unwrap();

assert_eq!(alice_key, bob_key);

A few notes on using this crate in production:

Identity strings matter. The identity parameters are included in the transcript hash. If Alice and Bob disagree on who is "a" and who is "b," key confirmation will fail. Establish a convention (e.g., initiator is always "a") and document it.

The message bytes are opaque. Do not parse or modify the byte vectors returned by start_a and start_b. Transmit them as-is. They contain the serialized group element. Tampering with them will cause a deserialization failure or a wrong key on the other side.

Error handling is critical. The finish method returns a Result. A failure means key confirmation did not pass. This is either a wrong password or an active attack. Log the failure, increment a counter, and enforce backoff. Do not retry automatically.

For the Ristretto255 group specifically, the crate uses curve25519-dalek with the ristretto feature. Ristretto provides a prime-order group abstraction over Curve25519, eliminating cofactor-related edge cases that plague raw Ed25519 point operations. This is the right choice for new implementations.

SPAKE2 vs. SPAKE2+

SPAKE2+ is an augmented variant where the server stores a verifier (derived from the password via a KDF) rather than the password itself. It adds one message to the protocol but provides the property that a server database compromise does not directly yield the password.

For the pairing use case, SPAKE2+ is unnecessary. Both peers hold the code -- there is no server to compromise. For client-server authentication, SPAKE2+ is relevant, but you should probably use OPAQUE instead, which provides stronger guarantees and cleaner separation of concerns.

Putting It All Together

Here is the checklist for a correct SPAKE2 integration:

Entropy. Use OS CSPRNG for ephemeral scalar generation. Verify the entropy pool is initialized before generating any cryptographic material.

M and N. Use the standard points from RFC 9382. Do not generate your own.

Transcript. Include identities, blinded values, and the shared secret in the KDF input. Use the exact transcript format specified in the RFC.

Key confirmation. Always perform it. Use constant-time comparison for the MAC check.

Code lifetime. Display the pairing code, use it once, discard it. Generate a fresh code for each attempt.

Rate limiting. Enforce lockout after three consecutive failures. Exponential backoff at minimum.

Post-SPAKE2. Exchange long-term public keys over the authenticated channel. Use those keys for future sessions. Do not re-run SPAKE2 with the same code.

Session keys. Derive per-direction subkeys from the SPAKE2 session key using HKDF with distinct labels. Use counter-based nonces for AES-GCM, or random nonces for XChaCha20-Poly1305.

SPAKE2 is the correct tool for one specific job: turning a low-entropy shared secret into an authenticated, forward-secret session key between two peers with no infrastructure dependencies. It is not a general-purpose authentication protocol. It is not a replacement for TLS, mTLS, or WebAuthn. When the pairing problem is your actual problem -- two devices, a short code, no CA -- SPAKE2 is the protocol you want. Use the standard points, include the full transcript, run key confirmation, and then move on to long-term keys as fast as possible.

The protocol is small. The implementation is straightforward. The security properties are well-understood and formally proven. The hard part is not the cryptography. The hard part is getting the integration right: entropy quality, code lifecycle, rate limiting, and the transition from pairing to long-term trust. Focus your review time there.