Elliptic-curve Diffie-Hellman (ECDH) lets two parties who have each published a public key compute the same secret without sending it. Each side multiplies the other's public point by its own private scalar; because scalar multiplication commutes, both land on the same point. It is the key agreement inside nearly every TLS 1.3 handshake, SSH session, Signal message and WireGuard tunnel.

This site already covers the surrounding material: the curve arithmetic in elliptic curve cryptography, the basic exchange and man-in-the-middle attacks in Diffie-Hellman key exchange, small-subgroup attacks and validation in the Diffie-Hellman attacks article, and fast field arithmetic in the ECC engine article. This page is about ECDH as a primitive you deploy. It builds X25519 from RFC 7748 in about 40 lines and verifies it against the RFC's test vector, explains what the clamping and the conditional swap are for, shows which inputs produce an all-zero secret and why protocols must reject them, contrasts that with the checks P-256 needs, and then packages ECDH as a key encapsulation mechanism exactly as HPKE (RFC 9180) does, verified against that RFC's test vector.

X25519: an x-only curve

X25519 works on Curve25519, the Montgomery curve y^2 = x^3 + 486662 x^2 + x over the prime field p = 2^255 - 19. It uses only the x-coordinate (called u in RFC 7748). That choice has three consequences. Public keys and shared secrets are 32 bytes. There is no point decompression and no sign bit to validate. And every 32-byte string is accepted as a u-coordinate: it either lies on the curve or on its quadratic twist, and Curve25519 was chosen so that the twist is also a strong group, so a hostile u off the curve does not leak the scalar the way invalid points do on curves without that property.

The group of points has order 8 times a large prime q (the cofactor is 8). A point in the small subgroup of order 1, 2, 4 or 8 is a low-order point. Multiplying one by a scalar that is a multiple of 8 gives the identity, which in x-only form is encoded as u = 0.

The Montgomery ladder, clamping and cswap

The Montgomery ladder computes k * P by walking the scalar's bits from the top while keeping two points whose difference is always P. Each step does one differential addition and one doubling, the same work whether the bit is 0 or 1. Only a conditional swap depends on the secret bit. This is RFC 7748 section 5 transcribed into Python:

P = 2**255 - 19
A24 = 121665                       # (486662 - 2) / 4

def decode_scalar(k: bytes) -> int:
    b = bytearray(k)
    b[0] &= 248                    # multiple of the cofactor 8
    b[31] &= 127                   # clear bit 255
    b[31] |= 64                    # set bit 254: fixed ladder length
    return int.from_bytes(b, "little")

def decode_u(u: bytes) -> int:
    b = bytearray(u)
    b[31] &= 127                   # ignore the top bit
    return int.from_bytes(b, "little") % P

def x25519(k: bytes, u: bytes) -> bytes:
    k, x1 = decode_scalar(k), decode_u(u)
    x2, z2, x3, z3, swap = 1, 0, x1, 1, 0
    for t in reversed(range(255)):
        kt = (k >> t) & 1
        swap ^= kt
        if swap:                   # NOT constant time; real code uses a masked cswap
            x2, x3, z2, z3 = x3, x2, z3, z2
        swap = kt
        A, B = (x2 + z2) % P, (x2 - z2) % P
        AA, BB = A * A % P, B * B % P
        E = (AA - BB) % P
        C, D = (x3 + z3) % P, (x3 - z3) % P
        DA, CB = D * A % P, C * B % P
        x3 = (DA + CB) ** 2 % P
        z3 = x1 * (DA - CB) ** 2 % P
        x2 = AA * BB % P
        z2 = E * (AA + A24 * E) % P
    if swap:
        x2, x3, z2, z3 = x3, x2, z3, z2
    return (x2 * pow(z2, P - 2, P) % P).to_bytes(32, "little")

BASE = (9).to_bytes(32, "little")

Clamping is what decode_scalar does. Clearing the low three bits makes every scalar a multiple of 8, so any low-order component in the peer's point is multiplied away instead of leaking three bits of the key. Setting bit 254 fixes the position of the top bit, so a ladder that starts at the highest set bit always runs the same number of steps. Clearing bit 255 keeps the scalar below 2^255.

The conditional swap is the only secret-dependent operation, and Python's if branches on it, so this code leaks timing. Production code computes a mask from the bit and swaps with arithmetic, and also uses constant-time field operations, which Python's big integers are not. Use this listing to understand and test; never deploy it. On the machine used here it took 1.75 ms per call against about 50 microseconds through the cryptography package.

Worked example: RFC vectors and a hostile key

RFC 7748 section 6.1 gives two private keys. Running the code above on them:

alice_sk = 77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a
alice_pk = 8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a
bob_sk   = 5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb
bob_pk   = de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f
x25519(alice_sk, bob_pk) == x25519(bob_sk, alice_pk)
         = 4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742

Both public keys and the shared secret match the RFC byte for byte, and the function agreed with the cryptography package on 200 further random key pairs. Now feed it a hostile public key. With u = 0, and with u = 1, Alice's key produces a shared secret of 32 zero bytes. Those inputs are low-order points, and clamping guarantees the result is the identity whatever the private key, so an attacker who sends one knows the 'secret' in advance. The cryptography package refuses: its exchange raised ValueError for the zero input.

That is why TLS 1.3 (RFC 8446, section 7.4.2) says implementations using X25519 or X448 MUST check whether the computed shared secret is all zeros and abort, and why HPKE requires the same check. The check is cheap: compare the output to zero in constant time. It matters most in protocols where a party might use a key it did not generate, or where the shared secret is the only input to authentication.

P-256 ECDH and invalid-curve attacks

NIST P-256 ECDH is also everywhere, but its failure modes differ. Points use both coordinates, encoded compressed (33 bytes) or uncompressed (65 bytes), and the curve formulas never use the constant b. An implementation that skips checking that the received point is on the curve can be fed a point on a different curve with the same a but a weak group, learn the private key modulo small primes from each response, and recombine the residues with the Chinese remainder theorem. This invalid-curve attack was described by Biehl, Meyer and Müller in 2000, and Jager, Schwenk and Somorovsky showed practical attacks against TLS implementations in 2015.

So the rule for P-256 is full public-key validation before use: the point is not the identity, both coordinates are in range, and the curve equation holds (the cofactor is 1, so no subgroup check is needed beyond that). Mainstream libraries do this when parsing a public key; the risk is in hand-rolled parsers and in hardware or embedded code that takes raw coordinates. The shared secret is the x-coordinate of the result, 32 bytes, and like X25519 it must go through a KDF before use.

ECDH as a KEM: DHKEM in HPKE

A raw ECDH output is not a key. It is a group element with structure, and it says nothing about which public keys produced it. Every real protocol hashes it, together with context, into keys. HPKE (Hybrid Public Key Encryption, RFC 9180) does this in the cleanest form, and it is worth copying even outside HPKE. HPKE is used by TLS Encrypted Client Hello, Oblivious HTTP and the MLS group messaging protocol.

HPKE turns ECDH into a key encapsulation mechanism (KEM): Encap(pkR) returns a shared secret and an encapsulation enc to send; Decap(enc, skR) recovers the same secret. For DHKEM(X25519, HKDF-SHA256), KEM id 0x0020, the sender makes a fresh ephemeral key, its public key is enc, and the ECDH output is fed through HKDF with labels and with both public keys as context:

import hashlib, hmac, os

SUITE = b"KEM" + (0x0020).to_bytes(2, "big")

def extract(salt, ikm):
    return hmac.new(salt, ikm, hashlib.sha256).digest()

def expand(prk, info, n):
    out, t, i = b"", b"", 1
    while len(out) < n:
        t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
        out, i = out + t, i + 1
    return out[:n]

def extract_and_expand(dh, kem_context):
    prk = extract(b"", b"HPKE-v1" + SUITE + b"eae_prk" + dh)
    info = (32).to_bytes(2, "big") + b"HPKE-v1" + SUITE + b"shared_secret" + kem_context
    return expand(prk, info, 32)

def encap(pk_r):
    sk_e = os.urandom(32)
    enc = x25519(sk_e, BASE)
    dh = x25519(sk_e, pk_r)
    if dh == bytes(32):
        raise ValueError("low-order public key")
    return extract_and_expand(dh, enc + pk_r), enc

def decap(enc, sk_r):
    dh = x25519(sk_r, enc)
    if dh == bytes(32):
        raise ValueError("low-order public key")
    return extract_and_expand(dh, enc + x25519(sk_r, BASE))

Run decap on the recipient key and enc from RFC 9180 appendix A.1 (base mode, X25519, HKDF-SHA256, AES-128-GCM) and it returns the published shared secret fe0e18c9...7d2ea1fc exactly. The labels give domain separation, so the same ECDH output can never collide with a key derived for another purpose, and putting both public keys in the context binds the secret to this pair of keys.

DHKEM(X25519, HKDF-SHA256): ECDH packaged as a key encapsulation mechanismSenderRecipient (static key skR, pkR)fresh ephemeral skEpkE = X25519(skE, 9)dh = X25519(skE, pkR)abort if all zeroExtractAndExpanddh, kem_context = pkE || pkRshared_secret (32 bytes)into the HPKE key scheduledh = X25519(skR, enc)abort if all zeroExtractAndExpandsame labels, same contextshared_secret (32 bytes)equal to the sender'senc = pkE (32 bytes) on the wireOne message, no round trip: the sender needs only pkR.Binding pkE and pkR into the KDF ties the secret to this exact pair of keys.
Encap on the left, Decap on the right; only enc crosses the wire.

The trade-off of this ephemeral-static pattern is forward secrecy. The sender's key is fresh, but the recipient's is long-lived: anyone who later steals skR can decrypt every recorded message sent to it. Interactive protocols such as TLS 1.3 use ephemeral keys on both sides for that reason, and rotate static HPKE keys on a schedule.

Operational guidance

  • Use a vetted library (libsodium, BoringSSL, the cryptography package, Go's crypto/ecdh) and treat this page's code as a test oracle, not an implementation.
  • Generate a fresh ephemeral key per exchange and erase it after deriving keys. A reused ephemeral key loses forward secrecy (one leak exposes every session that used it) and becomes a static key exposed to static-key attacks.
  • Never use the raw output as a key. Run it through HKDF with both public keys and a protocol label, as DHKEM does, or through the protocol's own key schedule; the TLS handshake article walks through TLS 1.3's.
  • Authenticate the public keys. ECDH alone gives no identity; bind keys with signatures, certificates or a pinned static key, or a man in the middle runs two exchanges.
  • Do not reuse a key across algorithms. Keep X25519 keys separate from Ed25519 signing keys even though the curves are related.
  • Plan for post-quantum. A large quantum computer would solve the elliptic-curve discrete log, so recorded traffic is at risk later. Deployments are adding hybrid key exchange that combines X25519 with ML-KEM; consult the current IETF documents for exact group names, and see the migration article linked below.

Failure modes

  • Skipping the all-zero check on X25519: a low-order public key forces a known secret.
  • Skipping point validation on P-256 or other short-Weierstrass curves: invalid-curve attacks recover static private keys.
  • Variable-time arithmetic: branches or table lookups that depend on scalar bits leak the key through timing and cache side channels.
  • Missing context in the KDF: without both public keys in the derivation, protocols become vulnerable to unknown-key-share and key-reuse confusion.
  • Assuming forward secrecy you lack: ephemeral-static encryption protects nothing once the static key leaks.

What to do next

  1. Run the ladder against the RFC 7748 vectors and the DHKEM against RFC 9180 A.1; keep both as regression tests for whatever library you deploy.
  2. Grep your code for ECDH calls and confirm each one feeds a KDF with context and that X25519 outputs are checked for all zeros.
  3. For every P-256 public key parser you own, confirm it rejects points not on the curve.
  4. List which of your exchanges are ephemeral-static and write down the rotation period for each static key.
  5. Read the post-quantum migration article and start an inventory of where ECDH protects long-lived data.
Key takeaway: ECDH gives two parties the same group element from their own private key and the other's public key. X25519 makes that a 32-byte, x-only operation whose clamped scalar kills small-subgroup leaks, but low-order inputs still force an all-zero secret that you must reject; P-256 instead needs full point validation. Never use the raw output as a key: derive keys as DHKEM does, with labels and both public keys, and know whether your pattern actually gives forward secrecy.