Federated fine-tuning keeps raw text on the devices or silos that own it, but the updates still travel. A LoRA delta computed on one hospital's notes or one phone's keyboard history can be inverted, membership-tested or simply memorised by whoever reads it. Secure aggregation (SecAgg) removes the server from that list. Clients mask their updates so that each upload looks like uniform noise, and the masks are built to cancel only when the server adds the uploads up. The server gets the sum it needs for FedAvg and nothing else.

This article builds the protocol of Bonawitz et al. (CCS 2017) from first principles, then covers float encoding, a runnable simulation, a worked LoRA cost example, SecAgg+ and the attacks SecAgg does not stop.

What secure aggregation promises, and what it does not

Be precise about the guarantee before building anything. With n clients holding vectors x1..xn, SecAgg lets the server compute the sum over the clients that finished the round. It must reveal nothing more about any individual vector, even if the server colludes with a bounded number of clients and even if some clients drop out partway through.

Three things are outside that promise, and most production incidents live there:

  • The sum itself leaks. If 99 of 100 contributions are known, the sum is the hundredth. Differential privacy covers this.
  • Cohort integrity. A server that admits 999 Sybil clients next to one real one learns that client's update exactly.
  • Update quality. The server cannot inspect, clip or reject a poisoned update.

Pairwise masks that cancel

Start with the core trick. Every pair of clients u and v agrees on a shared seed suv, normally through Diffie-Hellman: suv = KDF(DH(sku, pkv)) = KDF(DH(skv, pku)). A pseudorandom generator expands the seed into a vector as long as the update. The client with the smaller id adds that vector and the other subtracts it, all modulo 2k:

y_u = x_u + sum_{v > u} PRG(s_uv) - sum_{v < u} PRG(s_uv)      (mod 2^k)
sum_u y_u = sum_u x_u                                            (every PRG(s_uv) appears once with + and once with -)

Each yu on its own is uniformly distributed as long as at least one of u's pair seeds is unknown to the observer. Modular arithmetic is not optional: masking floats leaks magnitude through rounding, while a value masked modulo 2k is exactly uniform.

The catch is dropouts. If client w goes offline after the others have masked with suw but before w uploads, w's masks never arrive to cancel, and the sum becomes noise. On phones, dropouts every round are normal.

Dropouts, the self mask and the four rounds

Bonawitz et al. fix dropouts with threshold secret sharing and a second mask. In the key-sharing round each client splits two secrets into n Shamir shares with threshold t and sends one share of each, encrypted, to every peer: its DH mask key sku and a fresh self-mask seed bu. The upload becomes:

y_u = x_u + PRG(b_u) + sum_{v > u} PRG(s_uv) - sum_{v < u} PRG(s_uv)    (mod 2^k)

In the unmasking round the server tells survivors who dropped. For each dropped client w, survivors reveal their shares of skw. The server rebuilds skw, recomputes every svw and removes the orphaned pair masks. For each surviving client u, survivors reveal shares of bu so the server can remove the self mask.

The self mask exists because of a race. Suppose there were no bu and u's upload was merely slow. The server could declare u dropped, collect sku, strip all of u's pair masks and read xu in the clear. With bu in place, unmasking u's pair masks still leaves PRG(bu) on top. The security invariant follows: an honest client never reveals both the sk share and the b share for the same peer. With t greater than n/2, a lying server cannot assemble two disjoint quorums that tell different stories. Against a malicious server the paper adds signed keys, a consistency-check round and a larger threshold.

One secure-aggregation round: the server learns the sum, never a single updateClient uLoRA delta x_uClient vLoRA delta x_vClient wdrops after round 1R0 AdvertiseKeyssigned DH public keysR1 ShareKeysShamir shares of sk and bR2 MaskedInputy = x + PRG(b) + pair masksR3 Unmaskingb shares or sk shares, not bothServersum of y mod 2^32masked y onlyDecodesum of x, then meanPair mask s_uv = KDF(DH(sk_u, pk_v))u adds PRG(s_uv), v subtracts it: cancels in the sumSelf mask PRG(b_u)protects u if the server lies that u droppedSurvivor u: server rebuilds b_u from t shares. Dropout w: server rebuilds sk_w and cancels w's pair masks.Honest clients never release both secrets for the same user, so no individual x is ever exposed.
The four rounds of Bonawitz-style secure aggregation. The upload in R2 is uniform noise on its own; R3 removes exactly one kind of mask per client.

Encoding floats into a finite group

Gradients and LoRA deltas are floats, so they have to be mapped into the integers modulo 2k, and the mapping decides both accuracy and safety. Clip every update to a known bound, scale it to integers, round stochastically so the rounding stays unbiased, and leave enough headroom that the sum of n values cannot wrap. With k = 32 and n clients, each client can use magnitudes up to about 231/n, which costs log2(n) bits of resolution. Decoding reads the sum as a two's-complement integer and divides by the scale and then by the survivor count.

import numpy as np

MOD = 1 << 32

def encode(x, clip, n_clients, rng):
    L = (MOD // 2) // n_clients - 1                 # n * L < 2^31, so the sum cannot wrap
    x = np.clip(x, -clip, clip) * (L / clip)
    q = np.floor(x + rng.random(x.shape)).astype(np.int64)   # stochastic rounding: E[q] = x
    return (q % MOD).astype(np.uint64)              # two's complement inside Z_(2^32)

def decode(total, clip, n_clients):
    L = (MOD // 2) // n_clients - 1
    s = total.astype(np.int64)
    s[s >= MOD // 2] -= MOD
    return s * (clip / L)                           # the SUM; divide by survivors for the mean

In practice, clip the update's L2 norm first (DP accounting needs it), then apply the coordinate bound. Size L for the largest cohort you will ever admit, or a bigger round silently overflows.

A runnable simulation

The simulation below runs the whole protocol in one process, dropout recovery included. It uses a toy multiplicative DH group and NumPy's PCG64 as the PRG so that it runs anywhere. A real system uses X25519 with HKDF, an AES-CTR or ChaCha20 keystream, authenticated encryption for share transport, and signatures on every message.

import hashlib, random
import numpy as np

P, G = 2**127 - 1, 3          # Mersenne prime: toy DH group AND Shamir field (> 64-bit seeds)
MOD = 1 << 32

def prg(seed, n):
    return np.random.Generator(np.random.PCG64(seed)).integers(0, MOD, size=n, dtype=np.uint64)

def pair_seed(sk, pk_other):  # symmetric: pair_seed(sk_u, pk_v) == pair_seed(sk_v, pk_u)
    return int.from_bytes(hashlib.sha256(str(pow(pk_other, sk, P)).encode()).digest()[:8], "big")

def share(secret, t, ids):
    coef = [secret] + [random.randrange(P) for _ in range(t - 1)]
    return {i: sum(c * pow(i, k, P) for k, c in enumerate(coef)) % P for i in ids}

def reconstruct(shares):
    out = 0
    for i, y in shares.items():
        num = den = 1
        for j in shares:
            if j != i:
                num, den = num * -j % P, den * (i - j) % P
        out = (out + y * num * pow(den, -1, P)) % P
    return out

class Client:
    def __init__(self, uid, x):
        self.uid, self.x = uid, x                    # x already encoded mod 2^32
        self.sk = random.randrange(2, P - 1); self.pk = pow(G, self.sk, P)
        self.b = random.getrandbits(64)              # self-mask seed

    def masked(self, pks):
        y = (self.x + prg(self.b, self.x.size)) % MOD
        for v, pk in pks.items():
            if v != self.uid:
                m = prg(pair_seed(self.sk, pk), self.x.size)
                y = (y + m) % MOD if self.uid < v else (y - m) % MOD
        return y

def run_round(clients, survivors, t):
    ids = [c.uid for c in clients]
    pks = {c.uid: c.pk for c in clients}                                   # R0
    held = {c.uid: (share(c.sk, t, ids), share(c.b, t, ids)) for c in clients}  # R1
    ys = {c.uid: c.masked(pks) for c in clients if c.uid in survivors}      # R2
    if len(ys) < t:
        raise RuntimeError("fewer than t survivors: abort, never unmask")
    total, n = sum(ys.values()) % MOD, clients[0].x.size
    quorum = list(ys)[:t]
    for u in ys:                                     # R3: survivors -> b shares only
        total = (total - prg(reconstruct({v: held[u][1][v] for v in quorum}), n)) % MOD
    for w in (i for i in ids if i not in ys):        # R3: dropouts -> sk shares only
        sk_w = reconstruct({v: held[w][0][v] for v in quorum})
        for v in ys:
            m = prg(pair_seed(sk_w, pks[v]), n)
            total = (total - m) % MOD if v < w else (total + m) % MOD
    return total

Check it by encoding random vectors for ten clients, dropping two, and comparing decode(run_round(...)) with the plain sum of the survivors' clipped inputs. Then let the server collect both share types for one survivor and confirm its input comes out in the clear: keep that negative test in CI.

Worked example: a 7B LoRA federation with 500 clients

Take a cross-device fine-tune of a 7B Llama-style model with rank-16 LoRA on the query and value projections of all 32 layers. Each 4096×4096 projection gets A (16×4096) and B (4096×16), which is 131,072 parameters, so the adapter is 2 × 32 × 131,072 = 8,388,608 parameters.

QuantityValueWhy it matters
Masked upload at k = 328,388,608 × 4 B = 33.5 MBTwice a bf16 upload, and masked data does not compress
Cohort n = 500, headroomL = 231/500, about 4.29 millionClip 0.05 gives a step near 1.2e-8, finer than bf16 at that range
Client PRG work, full graph500 expansions × 33.5 MB, about 16.8 GB of keystreamFine on a server CPU, prohibitive on a phone
Client PRG work, 40 neighbours41 expansions, about 1.4 GBWhy SecAgg+ exists
Server unmasking, 10% dropout, full graph50 dropouts × 450 survivors = 22,500 expansionsAbout 750 GB of keystream: the real bottleneck

The last row is Bonawitz's quadratic server cost made concrete: dropout handling, not masking, dominates a full-graph round.

Scaling: SecAgg+ and the alternatives

SecAgg+ (Bell, Bonawitz, Gascón, Lepoint and Raykova, CCS 2020) replaces the complete graph with a random sparse graph in which each client masks with, and shares secrets with, only about O(log n) neighbours. Client cost becomes polylogarithmic in n, and server unmasking scales with dropouts times neighbourhood size instead of dropouts times n. The price is a probabilistic guarantee: the neighbourhood must be large enough that, with overwhelming probability, no honest client's neighbourhood is entirely corrupted or entirely dropped. Pick the degree from the paper's bounds for your assumed corruption and dropout rates, not from intuition.

Cross-silo deployments with a dozen participants often skip the PRG-mask design entirely. They use additive secret sharing across two or three non-colluding aggregators, or run the aggregator inside a TEE with remote attestation (see confidential computing for LLMs). A TEE trades cryptographic guarantees for hardware trust, but it can inspect and reject individual updates.

Differential privacy and poisoning under SecAgg

SecAgg and the other defences pull against each other in two places.

Differential privacy. Central DP adds noise at the server, which is trusted to see the clean sum before noising. Under SecAgg, nobody sees that sum. The fix is distributed DP: each client adds a share of the noise so the total carries the full amount. The noise has to be discrete, because it lives in Z2k. The distributed discrete Gaussian (Kairouz et al., ICML 2021) and the Skellam mechanism (Agarwal et al., NeurIPS 2021) were designed for this. Size each client's share for the minimum cohort, since dropouts reduce the total noise. The privacy budget guide covers the accounting.

Poisoning. Robust aggregators such as coordinate-wise median, Krum and trimmed mean need to see individual updates. SecAgg forbids that. What is left is client-side clipping, which a malicious client simply skips; norm checks proven in zero knowledge (research systems such as RoFL); or a TEE that inspects updates. If your threat model includes poisoned contributors, read data poisoning in depth and pick one explicitly.

Failure modes

  • Overflow on a bigger cohort. Headroom sized for 200 clients, then 800 joined: the sum wraps silently. Assert the cohort size before round 1.
  • Unmasking below threshold. A server that proceeds with fewer than t survivors, or reuses shares across rounds, breaks the invariant. Abort the round instead.
  • Reused self-mask seeds. If bu survives into the next round, last round's revealed bu unmasks this round. Every secret is fresh per round.
  • Unauthenticated keys. Without signed public keys, the server can man-in-the-middle the DH exchange and learn every pair seed. This is the most common shortcut in homegrown implementations.
  • Sybil cohorts. Fake clients fill the cohort. Gate admission on attestation or enrolled identities.

What to do next

  1. Write the threat model in one paragraph: honest-but-curious or malicious server, how many colluding clients, expected dropout rate.
  2. Fix the cohort bounds (minimum and maximum n) and derive the threshold t, the encoding headroom and, under SecAgg+, the neighbourhood degree from them.
  3. Run the simulation with your real adapter size and worst-case dropout rate to measure bytes and unmasking time.
  4. Use an audited library rather than the toy (for example Flower's SecAgg+ workflow or TensorFlow Federated's secure aggregators), and confirm it signs keys and runs the consistency check.
  5. Add distributed discrete noise sized for the minimum cohort, and record the epsilon per round in your budget ledger.
  6. Decide how poisoning is handled (TEE, ZK norm proofs or accepted risk) and write the decision down.
  7. Keep a negative test that proves revealing both share types for one client exposes its input, and that your server code refuses to do it.
  8. Continue with federated learning for LLMs for the training side and backdoor detection for post-hoc model checks.
Key takeaway: Secure aggregation masks each client update with pairwise masks that cancel in the sum and a self mask that protects slow clients, and it uses Shamir shares to recover from dropouts without ever revealing both secrets for one client. Encode with clipping, stochastic rounding and log2(n) bits of headroom. Budget for server unmasking cost, or move to SecAgg+. Pair it with distributed discrete DP and an explicit poisoning decision, because SecAgg hides inputs and nothing else.