A side-channel attack recovers a secret not by breaking the mathematics of an algorithm but by observing how a particular implementation runs: how long it takes, which cache lines it touches, how much power it draws, what it leaves behind after speculative execution. AES, RSA and HMAC are all believed sound as mathematics. Implementations of each have leaked keys through timing, cache and power, sometimes over a network.

This article is written for engineers who write or review code that handles secrets. It explains the families of channels from first principles, works through why a byte-by-byte comparison is catastrophic, shows the constant-time coding rules with real code, and ends with a way to test your own code for leaks. The algorithms themselves are covered in AES, RSA and the SHA family; here the subject is what happens when they run on real hardware.

The model: secret, implementation, channel

A side channel: the secret leaks through how the computation runsSecretkey, token, passwordImplementationbranches, table lookupsOutputcorrect resultTimeresponse latencyCachelines touchedPower / EMswitching activitySpeculationtransient loadsStatisticsmany traces -> key bitsthe maths of the algorithm can be perfect; the leak is in data-dependent work
The secret flows into an implementation whose resource use depends on it. Each observable resource is a channel; statistics over many observations recover bits.

The model has three parts. A secret (a key, a session token, a password hash) is processed by an implementation. If anything about that execution depends on the secret, such as a branch taken, a memory address read or the number of loop iterations, then some physical or microarchitectural resource varies with the secret too. The attacker measures that resource, usually many times, and uses statistics to separate the signal from noise.

The leak per measurement is often tiny: tens of nanoseconds across a network with milliseconds of jitter. That does not make it safe. Noise averages out as the square root of the number of samples, so a signal 100 times smaller than the noise needs roughly 10,000 samples per hypothesis to stand out. Attackers who can make unlimited requests have that budget.

The key engineering insight is that the fix is never "add noise". Random delays only raise the sample count. The fix is to make the work independent of the secret.

The families of channels

Timing. Paul Kocher's 1996 paper showed that the time taken by modular exponentiation in RSA and Diffie-Hellman implementations depended on key bits, because square-and-multiply only multiplies when a bit is 1. Brumley and Boneh showed in 2003 that the same class of leak in OpenSSL's RSA was exploitable across a local network. In 2013, the Lucky Thirteen attack by AlFardan and Paterson used small timing differences in how TLS implementations processed CBC padding and MACs to recover plaintext. Timing is the channel that reaches furthest, because any network service exposes latency.

Cache. On a shared CPU, an attacker process cannot read your memory but can observe which cache lines you used. In Flush+Reload (Yarom and Falkner, 2014) the attacker flushes a line of shared memory, such as a shared library page, waits, then times a reload: a fast reload means the victim touched it. Prime+Probe works without shared memory by filling a cache set and timing which of its own lines were evicted. Table-based AES, where lookups are indexed by key-dependent bytes, was the classic victim. That is one reason modern CPUs provide AES instructions and libraries prefer them or bitsliced code.

Power and electromagnetic. Kocher, Jaffe and Jun introduced simple and differential power analysis in 1999. CMOS logic draws current when bits switch, so power traces correlate with the Hamming weight of intermediate values. With physical access to a smart card, microcontroller or hardware wallet, correlation power analysis can recover an AES key from a few thousand traces against an unprotected implementation. This is the dominant concern for embedded and IoT devices.

Speculative execution. Spectre and Meltdown (disclosed 2018) showed that CPUs executing instructions speculatively, then discarding the results, still leave traces in the cache. A mispredicted bounds check can load out-of-bounds secret data, use it to index an array, and leave a cache footprint that a timing probe can read back. Mitigations live in microcode, compilers, operating systems and browsers. For application code the lesson is that even a branch you wrote correctly can execute the wrong way for a few hundred cycles.

Other channels exist: acoustic emanations, fault injection (glitching voltage or clock to induce errors, closely related), and remote power estimation through frequency scaling. The defensive pattern is the same for all of them.

Worked example: the leaky comparison

Early-exit comparison turns a 2^128 search into 16 x 256 guessesguess byte 0256 candidatesguess byte 1256 candidates...byte 15256 candidatesLeaky: return at first mismatchtime grows with matched prefixConstant time: OR all differencestime independent of dataslowest candidate = correct byteall candidates look the same4,096 guesses x samples per guess, instead of brute force over the whole token
Why a comparison that returns early leaks: each byte can be guessed independently by timing.

The most common side channel in application code is also the simplest. Here is the comparison many developers write to check an API token or HMAC tag:

def insecure_equals(a: bytes, b: bytes) -> bool:
    if len(a) != len(b):
        return False
    for x, y in zip(a, b):
        if x != y:
            return False      # returns sooner the earlier the first mismatch
    return True

Worked example. Suppose a server checks a 16-byte MAC this way. Brute force over the whole tag is 2128 guesses, which is hopeless. But the loop runs one iteration longer for each leading byte that matches. An attacker sends 256 requests that differ only in byte 0, repeats each many times, and the candidate with the longest median response time is the correct byte 0. Then they fix byte 0 and repeat for byte 1. The search becomes 16 × 256 = 4,096 hypotheses, each needing enough samples to beat noise. If one iteration costs 1 ns and network jitter is 100 microseconds, the signal-to-noise ratio is 1 in 100,000 per sample and an attack over the internet becomes impractical. On a co-located machine or the same host the jitter can be a few microseconds or less, and the arithmetic changes completely. Do not bet on the network.

The fix is to compare all bytes regardless of where they differ, and accumulate differences with operations that do not branch:

/* C: constant-time equality for equal-length buffers. */
int ct_equal(const unsigned char *a, const unsigned char *b, size_t n) {
    unsigned char diff = 0;
    for (size_t i = 0; i < n; i++)
        diff |= a[i] ^ b[i];            /* no branch on data */
    /* map 0 -> 1, nonzero -> 0 without a data-dependent branch */
    return (int)((((unsigned)diff - 1u) >> 8) & 1u);
}
# Python: use the standard library, never ==, for secrets.
import hmac
def verify_tag(expected: bytes, received: bytes) -> bool:
    return hmac.compare_digest(expected, received)

Library functions exist for exactly this: hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js, MessageDigest.isEqual in Java, subtle.ConstantTimeCompare in Go and CRYPTO_memcmp in OpenSSL. Note that ct_equal still leaks the length; that is acceptable when the length is public, such as a fixed MAC size. Comparing hashes of both values first, then comparing the hashes, is another pattern for variable-length secrets.

Constant-time coding rules

Constant-time code means code whose sequence of instructions, memory addresses and operand-dependent timing do not depend on secret values. The practical rules:

  1. No branches on secrets. Replace if (secret_bit) x = a; else x = b; with a mask select: mask = -(uint64_t)bit; x = (a & mask) | (b & ~mask);. The bit hacks article covers the underlying tricks.
  2. No memory addresses derived from secrets. No table lookups indexed by key bytes. If you must look up, read every entry and select with masks, or use hardware instructions such as AES-NI.
  3. No loop bounds derived from secrets. Modular exponentiation should process a fixed number of bits with a fixed operation sequence, for example a Montgomery ladder that does one multiply and one square per bit regardless of its value.
  4. Avoid variable-time instructions on secrets. Integer division and some multiplications have data-dependent latency on some CPUs. Floating point can be slow on subnormal values.
  5. Do not trust the compiler to keep it constant time. Optimisers may turn a mask select back into a branch, or short-circuit a loop once a value is known. Verified libraries use compiler barriers, inspect generated assembly and test.
  6. Errors are channels too. Distinguishable errors (bad padding versus bad MAC) or different code paths for "user not found" versus "wrong password" leak just as much as timing. Return one error after doing the same work.

Testing for leaks

You cannot review your way to constant time; you must measure. The dudect approach (Reparaz, Balasch and Verbauwhede, 2017) is simple enough to adopt in a test suite. Run the function many times with two classes of input, typically a fixed secret and random secrets, interleaved randomly, recording cycle counts. Then apply Welch's t-test to the two distributions. If the timing does not depend on the input class, the t-statistic stays small. The test vector leakage assessment (TVLA) methodology used for hardware commonly flags |t| above 4.5 as evidence of leakage.

import os, random, statistics, time

def welch_t(xs, ys):
    mx, my = statistics.fmean(xs), statistics.fmean(ys)
    vx, vy = statistics.variance(xs), statistics.variance(ys)
    return (mx - my) / ((vx / len(xs) + vy / len(ys)) ** 0.5)

def leakage_test(fn, secret_len=32, n=200_000, crop=0.9):
    reference = os.urandom(secret_len)
    # fixed class: a near-miss that matches every byte but the last (worst case for
    # an early-exit compare); random class: almost always differs at byte 0
    fixed = reference[:-1] + bytes([reference[-1] ^ 1])
    timings = {0: [], 1: []}
    for _ in range(n):
        cls = random.getrandbits(1)                 # interleave classes randomly
        probe = fixed if cls == 0 else os.urandom(secret_len)
        t0 = time.perf_counter_ns()
        fn(reference, probe)
        timings[cls].append(time.perf_counter_ns() - t0)
    # crop the slow tail (interrupts, scheduler) before testing, as dudect does
    for k in timings:
        xs = sorted(timings[k])
        timings[k] = xs[: int(len(xs) * crop)]
    return welch_t(timings[0], timings[1])

# Run each implementation (insecure_equals, hmac.compare_digest) and compare |t|
# against the 4.5 threshold.

In our run with 50,000 samples, the early-exit compare scored |t| in the thousands. hmac.compare_digest scored in the teens: still above 4.5, most likely from interpreter and memory effects (the fixed probe is one warm object, random probes are fresh ones) rather than a content leak. That is the limit of a Python harness. It shows gross leaks clearly, but for real verdicts on C and Rust code use the dudect C harness or a cycle counter, on the real build, CPU family and compiler flags you ship. Static tools complement this. One technique marks secret memory as uninitialised for Valgrind's memcheck, so any branch or address computed from it is reported, an approach known as ctgrind. A failing test is a definite bug. A passing test means no leak was found at that sample size, on that machine, which is weaker. Treat it like a fuzzer, not a proof.

Defence in depth

Beyond constant-time code, defences layer by threat model:

  • Network services: constant-time comparisons and crypto libraries, uniform error responses, rate limiting and lockout so an attacker cannot collect hundreds of thousands of samples per hypothesis.
  • Shared hardware: keep high-value keys out of processes that share cores with untrusted tenants, disable memory deduplication across trust boundaries (it enables Flush+Reload on shared pages), and keep microcode, kernel and hypervisor mitigations current. Hardware enclaves, discussed in SGX for LLM security, have their own long list of side-channel papers and are not a substitute for constant-time code.
  • Physical devices: masking (splitting each secret into random shares processed separately), shuffling operation order, hardware noise sources and certified secure elements. Masking raises the order of statistics an attacker needs, and the trace count rises steeply with that order.
  • Key rotation and blinding: RSA blinding multiplies the input by a random value before exponentiation, so timing no longer correlates with the attacker's chosen input. Short-lived keys limit how many traces any single key yields.

Trade-offs

ChannelAttacker needsTypical victimMain defence
Remote timingMany network requestsToken compare, padding checks, RSAConstant-time code, uniform errors, rate limits
Cache timingCode on the same CPU or cacheTable-based AES, key-dependent lookupsNo secret-indexed memory, AES instructions, isolation
SpeculativeCode on the same CPU, gadgetsBrowsers, kernels, hypervisorsMicrocode, compiler fences, site isolation
Power / EMPhysical access and probesSmart cards, IoT, walletsMasking, shuffling, secure elements

Constant-time code costs performance: no early exits, no lookup tables, sometimes twice the work in a ladder. For the small amount of code that touches secrets, pay it. For everything else, it is unnecessary.

What to do next

  1. Search your codebase for == and memcmp on tokens, MACs, password hashes and API keys; replace them with your language's constant-time comparison.
  2. Make authentication and decryption failures return one error type after the same work, regardless of which check failed.
  3. Use a maintained crypto library for primitives; never implement table-based ciphers or variable-time bignum arithmetic yourself.
  4. Add a dudect-style t-test to CI for any function you write that handles secrets.
  5. Rate-limit authentication endpoints so timing statistics are too expensive to collect.
  6. For multi-tenant hosts, review core sharing, memory deduplication and mitigation settings with your platform team.
  7. Read password hashing with bcrypt next for the storage side of credential handling.
Key takeaway: A side channel leaks a secret through how code runs rather than what it computes. Timing, cache, power and speculative channels all exploit work that depends on secret data, and statistics defeat noise given enough samples. Make secret-handling code constant time with no secret branches, addresses or loop bounds, use library comparisons, return uniform errors, and test for leakage with a t-test rather than trusting review.