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.
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 meanIn 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 totalCheck 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.
| Quantity | Value | Why it matters |
|---|---|---|
| Masked upload at k = 32 | 8,388,608 × 4 B = 33.5 MB | Twice a bf16 upload, and masked data does not compress |
| Cohort n = 500, headroom | L = 231/500, about 4.29 million | Clip 0.05 gives a step near 1.2e-8, finer than bf16 at that range |
| Client PRG work, full graph | 500 expansions × 33.5 MB, about 16.8 GB of keystream | Fine on a server CPU, prohibitive on a phone |
| Client PRG work, 40 neighbours | 41 expansions, about 1.4 GB | Why SecAgg+ exists |
| Server unmasking, 10% dropout, full graph | 50 dropouts × 450 survivors = 22,500 expansions | About 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
- Write the threat model in one paragraph: honest-but-curious or malicious server, how many colluding clients, expected dropout rate.
- Fix the cohort bounds (minimum and maximum n) and derive the threshold t, the encoding headroom and, under SecAgg+, the neighbourhood degree from them.
- Run the simulation with your real adapter size and worst-case dropout rate to measure bytes and unmasking time.
- 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.
- Add distributed discrete noise sized for the minimum cohort, and record the epsilon per round in your budget ledger.
- Decide how poisoning is handled (TEE, ZK norm proofs or accepted risk) and write the decision down.
- 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.
- Continue with federated learning for LLMs for the training side and backdoor detection for post-hoc model checks.