Skip to main content
Basic Classic Symmetric Breakers Intermediate

Breaking the Autokey Cipher

The Autokey cipher defeats Kasiski examination and the Index of Coincidence by never repeating its key. But the short priming key that starts everything off is still just a handful of letters, and one wrong guess collapses the entire decryption for you.

PL
Pashalis Laoutaris
August 22, 2026
34 min read

Interactive Autokey Cipher Breaker

🔐 Autokey Cipher Breaker

6
Simplified model: assumes a plaintext-autokey cipher with a priming key of at most 4 letters. That's 26 + 26² + 26³ + 26⁴ = 475,254 possible priming keys, small enough to brute-force. Each full decryption is scored by how well its letter pairs match real English. One wrong priming-key letter cascades through the self-referencing key chain and corrupts nearly everything after it, so the correct key stands out sharply from every wrong guess.
Enter text and click a button to start!
Letter 1
Letter 2
Letter 3
Letter 4
Testing priming keys of length 1 / 4
Primer Score Decrypted Preview
ZVPDNASKYRQSTFGZGLQFMIEHCIHXYMDHZKDDBVTRGMLRGDHSBCKAMPKUMKAFQBNWMDRQHHSAEQTBMBNBRWPRVEHMCEEFGBXPXTYARETBUXYXFWDVCSFFRCQHUGWAVVLWFZFXEOWMLXERQFOGTINMIUMIYIFXEOWMLOCWRQDDNAFLFDSOYWWOULLDMUYRKFPHYPVLESEYHVVIDTMCUEENNLTNCDXRYRSUKLGQCXCACPSGQWVLKMCDGFGJCOVPCKSJQXPDWXSTQXEOWMLAZXMMDFSJSMWFRATZRLFYKELQKVYBTVKFTXGAMVRFMJWMKWOIZLAIVOKUGZYURVKCBUWVVVVXZYCXSALBNYTZRSEVRGWMFNWMLAPVITMIHTIKALTBKKTAKFGDMJXZRCCSGZPVTGCNHRPAPRKLMCKPQXEOWMLJOVYLPJEDPJCNYSQWKVWRJFIGMPVMAVFNNYHBKZXSJJIQJIISGZRTMMLIREGPZOIYECSEQJWKCRVFLCXIRYYFSGCDZWUWXMKRURGYMFYYSAKROTESSEUAEXLUMXHTYPLTJAAPPGHYVGQKGVKFXEOWMLJOGPBTVJNTKXTGKQCHKZKYYSHXYCAMIQYTVLMPVTEPKHPHTFXKTFXJXYCGEJR

Introduction

The Autokey cipher was a genuine historical fix for Vigenère’s fatal flaw. Instead of repeating a short keyword over and over, it extends the key with the plaintext itself, the plaintext-autokey variant, where the key stream literally is the message being encrypted, one step behind. This post, and the breaker below, targets exactly that variant. (The ciphertext-autokey variant is different, and easier to attack, as the FAQ and Limitations sections explain.) That way the key stream never cycles, and Kasiski examination has no periodicity to grab onto. That closes off the exact attack this site’s Vigenère and Beaufort breakers rely on. But Autokey’s own guide already gives away where the real weakness still lives: “a short, guessable, or previously-seen priming key remains a single point of failure. Once it’s known, the rest cascades open.” This post builds exactly that attack. The priming key is almost always only a few letters, short enough to brute-force outright. Because every later key letter is derived from the plaintext one step at a time, a single wrong priming-key letter doesn’t just corrupt one position. It derails the entire self-referencing chain that follows it. That makes the scoring problem almost embarrassingly easy once the search space itself is small enough to enumerate.

Table of Contents

Why Kasiski Examination and the Index of Coincidence Don’t Work Here

The base Autokey guide already covers this in detail, but it’s worth restating why the two techniques this site’s other polyalphabetic breakers depend on are dead ends here.

Both Kasiski examination and the Index of Coincidence lean on the same fact about a repeating Vigenère key: ciphertext position i’s shift depends only on i mod (key length), a fixed value shared by every position with the same residue. Splitting the ciphertext into that many interleaved streams turns each one into a plain single-shift Caesar cipher. That’s exactly what lets chi-squared analysis solve each stream independently, and what lets Kasiski’s repeated-trigraph distances reveal the length in the first place.

Autokey breaks that assumption immediately. Past the priming key, position i’s key letter is plaintext position i − L, a value that’s different at essentially every occurrence, not a constant tied to i mod L. There is no length L for which interleaving produces uniform-shift columns, so chi-squared per column has nothing to lock onto. Two ciphertext trigraphs only repeat if their key trigraphs also happen to match. Since the key past the primer is the plaintext, that means the plaintext six letters earlier would also have to coincidentally repeat. That’s a far rarer coincidence than the plain trigraph repeats Kasiski hunts for in a repeating-key cipher. So genuine Autokey ciphertext essentially never hands over a usable Kasiski signal.

The Attack: Brute-Force the Priming Key, Let the Cascade Do the Rest

None of that makes Autokey unbreakable. It just means the target of the attack has to change. The key stream is unbounded, but the priming key that generates it is not; historically it’s rarely more than a handful of letters. That reframes the problem back into one this site’s other brute-force breakers (Caesar, Affine, the simplified Enigma) already use:

  1. Bound the priming key length. This breaker searches every priming key from 1 up to 4 letters: 26 + 26² + 26³ + 26⁴ = 475,254 candidates total, small enough to enumerate completely in a browser tab.
  2. For each candidate primer, decrypt the entire message. Decryption from a guessed primer is fully deterministic and needs only one forward pass: the primer covers the first L positions, and every position after that reuses whatever plaintext letter was just recovered L steps earlier.
  3. Score the result with digram statistics: the same letter-pair log-frequency approach the Enigma breaker uses, and for the same reason. Autokey ciphertext is conventionally a continuous, unspaced letter stream, so there are no word boundaries for a common-word bonus to anchor against.
  4. Keep the highest-scoring candidate. Because the search is exhaustive within the bounded length, the winner is guaranteed to be the best-scoring primer in the entire search space, not just the best one stumbled into.

The reason this works so decisively comes straight from Autokey’s own design. In a repeating-key Vigenère, a wrong key letter only ever misdecrypts the ciphertext positions that share its column. Every other column is unaffected, so a wrong guess still produces text that’s partly plausible. Autokey has no such isolation. Get priming-key letter j wrong, and every ciphertext position at index j, j+L, j+2L, … downstream inherits that error through the self-referencing key chain. Each of those errors seeds the next one in turn. One wrong letter doesn’t stay local. It cascades outward until it has corrupted almost the entire rest of the message. Bigram scoring, which would give an ambiguous signal against a Vigenère-style partial corruption, gives an extremely sharp one here.

A Worked Example

Using the visualizer’s default ciphertext (639 letters, plaintext-autokey, priming key GOLD):

  1. The search works through all 475,254 candidate priming keys, one length at a time. The best score found within each length tells its own story:

    Length Best primer found Score
    1 X -2249.49
    2 NR -2234.83
    3 OTY -2159.12
    4 GOLD -662.23

    These scores are summed log-likelihoods, so less negative always means a better fit; a score of -662.23 beats -2249.49 the same way a higher positive number would. They’re also specific to this breaker’s particular bigram table and its floor constant for unseen pairs. A different table would shift every score by roughly a constant offset. Only the relative ranking matters, not the raw numbers, so there’s no universal “good score” threshold to memorize here.

    Lengths 1 through 3 never escape gibberish-level scores. Nothing wrong-length can decrypt correctly, so every candidate at those lengths still cascades into noise. Length 4 is where the score jumps by nearly 1,500 points in one step, a dead giveaway that the true primer length has been reached.

  2. Within length 4, GOLD wins outright at -662.23. The next-best candidate anywhere in the entire 475,254-key search is GOBD at -1143.47, 481.24 points behind. Unlike the simplified Enigma breaker’s occasional double-stepping tie, there’s no ambiguity here at all: the gap between “correct” and “everything else” is enormous.

  3. That gap is the cascade effect in action. GOBD differs from GOLD in exactly one letter (position 2: B instead of L), and its decryption shows precisely where that shows up:

    GOLD → THEAUTOKEYCIPHERREMOVESTHEPERIODICPATTERNTHATKASISKIEXAMINATIONDEPENDS...
    GOBD → THOAUTEKEYMIPHURREWOVEITHEZERIEDICZATTURNTRATKQSISUIEXQMINKTIODDEPONDS...

    The first two letters (TH, from the correct G and O) decrypt identically in both, since GOLD and GOBD agree there. Position 2 is where they split: the ciphertext letter is P (index 15) either way, but D(y) = (y − k) mod 26 gives a different answer depending on the key letter it’s fed. With the correct L (index 11): (15 − 11) mod 26 = 4E. With the wrong B (index 1): (15 − 1) mod 26 = 14O. That single substitution, E becoming O, is where the two decryptions part ways, and every letter after that diverges further as the error feeds into the self-referencing key chain. A primer wrong by a single letter is already unreadable past its third character.

  4. Decrypting with GOLD recovers a coherent passage: the one this article uses to describe its own attack:

    “The autokey cipher removes the periodic pattern that Kasiski examination depends on by folding the plaintext itself into the key stream once the short priming key runs out. But that priming key is still usually only a handful of letters long, which means an attacker who does not know it can simply try every possible priming key up to some reasonable length, decrypt the entire message with each guess, and score the result against real English letter pair statistics…”

Python Implementation

The interactive visualizer above runs this exact pipeline in JavaScript: same bounded search, same digram table. Here’s the same attack in Python.

Key Features

  • Faithful autokey mechanics: autokey_decrypt reproduces the same single-pass, self-extending key logic as this site’s Autokey cipher visualizer. The primer covers the first len(primer) positions, and each newly-recovered plaintext letter immediately extends the key stream len(primer) positions further ahead.
  • Exhaustive, but bounded: Unlike the Enigma breaker’s fixed 17,576-key search, Autokey’s priming key has no fixed length. So break_autokey explicitly bounds it: every length from 1 up to max_len (4 by default) is searched completely, and the winner is the best score found across all of them.
  • Pure digram scoring: Just like the Enigma breaker, there’s no common-word bonus and no word-boundary information. Real Autokey traffic is a continuous letter stream, so the scorer relies entirely on letter-pair statistics.

Code

# autokey_breaker.py
#
# Breaks a plaintext-autokey cipher by assuming the priming key is at
# most 4 letters long. That's 26 + 26^2 + 26^3 + 26^4 = 475,254
# possible priming keys -- small enough to brute-force outright,
# decrypting fully with each guess and scoring the result with digram
# (letter-pair) statistics. One wrong priming-key letter cascades
# through the self-referencing key chain and corrupts nearly every
# letter after it, so the correct key's score stands far above every
# wrong guess.

import math

ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
MAX_LEN = 4

BIGRAMS = {"HE": 3.5637, "TH": 3.3246, "ER": 2.706, "IN": 2.4173, "AN": 1.9097, "RE": 1.7599, "HA": 1.5132, "EN": 1.4942, "ON": 1.4774, "OU": 1.4742, "AT": 1.3883, "ND": 1.3435, "ED": 1.2794, "TO": 1.2408, "IS": 1.1669, "IT": 1.1623, "AS": 1.1473, "NG": 1.1462, "HI": 1.0715, "TE": 1.0665, "VE": 1.0235, "AR": 0.9992, "BE": 0.9838, "OF": 0.9627, "NE": 0.9617, "OR": 0.931, "SE": 0.9101, "ST": 0.8866, "ES": 0.8682, "LE": 0.8646, "NT": 0.8177, "NO": 0.8058, "TI": 0.7947, "EL": 0.7821, "LL": 0.7803, "ME": 0.7578, "LI": 0.7559, "EA": 0.75, "CO": 0.7481, "SH": 0.699, "AL": 0.6925, "OT": 0.6475, "WA": 0.6392, "HO": 0.6282, "UR": 0.6195, "ET": 0.617, "DE": 0.6119, "WI": 0.5965, "CE": 0.5951, "LY": 0.5938, "AD": 0.5795, "WH": 0.5568, "CH": 0.5531, "IO": 0.5481, "OM": 0.5276, "FO": 0.5276, "SI": 0.5106, "RI": 0.501, "YO": 0.4978, "DI": 0.4681, "RS": 0.4594, "IL": 0.4559, "RO": 0.4546, "EE": 0.4546, "MA": 0.4532, "SS": 0.4481, "US": 0.4474, "UT": 0.4426, "SO": 0.4332, "OW": 0.4327, "WE": 0.4213, "UL": 0.4201, "PE": 0.4183, "IM": 0.4061, "EC": 0.3951, "LD": 0.3948, "NC": 0.3925, "AI": 0.3723, "RA": 0.3703, "GH": 0.3696, "TA": 0.3682, "AB": 0.368, "IC": 0.3618, "MI": 0.3602, "SU": 0.3576, "IE": 0.3565, "NS": 0.3535, "IR": 0.353, "LA": 0.3443, "UN": 0.3397, "EV": 0.3381, "CA": 0.3374, "EM": 0.3264, "GE": 0.3252, "PR": 0.325, "AV": 0.323, "SA": 0.3188, "LO": 0.3184, "RY": 0.3151, "CT": 0.3151, "ID": 0.3131, "AM": 0.3122, "MO": 0.3112, "OO": 0.3108, "RT": 0.3078, "AY": 0.286, "TT": 0.2807, "AC": 0.2729, "EY": 0.2724, "OS": 0.2708, "FE": 0.2704, "MR": 0.2669, "BU": 0.2651, "EI": 0.2522, "KE": 0.2517, "DA": 0.2501, "PO": 0.2451, "OL": 0.2446, "UC": 0.2442, "BL": 0.2439, "WO": 0.2416, "PA": 0.2364, "TR": 0.2297, "HT": 0.2281, "IG": 0.2194, "DO": 0.218, "IV": 0.2145, "PL": 0.2104, "NI": 0.2083, "VI": 0.206, "UG": 0.2051, "RD": 0.2035, "AG": 0.2017, "FI": 0.2012, "FR": 0.1994, "MY": 0.1941, "AP": 0.1927, "FA": 0.1877, "NN": 0.1867, "TY": 0.1844, "IZ": 0.1838, "TU": 0.1812, "IF": 0.1766, "EX": 0.1759, "GA": 0.1716, "MP": 0.1714, "IA": 0.1702, "MU": 0.1693, "BY": 0.1681, "TL": 0.1681, "SP": 0.1663, "PP": 0.1642, "RC": 0.1619, "EF": 0.1596, "ZA": 0.1539, "BO": 0.1537, "GR": 0.1525, "LF": 0.1498, "QU": 0.1488, "OP": 0.1477, "EP": 0.1463, "RN": 0.1413, "NY": 0.1401, "FF": 0.1397, "AK": 0.139, "NA": 0.1383, "RR": 0.1374, "GI": 0.1309, "AU": 0.1298, "GO": 0.1293, "OV": 0.1279, "OD": 0.1279, "CI": 0.1263, "TS": 0.1245, "CY": 0.1217, "KI": 0.1171, "GL": 0.1169, "RL": 0.1137, "LU": 0.1116, "KN": 0.1112, "WN": 0.1107, "AF": 0.1103, "UA": 0.1075, "UP": 0.1075, "CK": 0.1045, "BI": 0.1018, "FT": 0.1015, "PI": 0.1008, "DY": 0.0983, "RM": 0.0976, "DS": 0.0942, "UE": 0.0935, "OK": 0.0919, "NL": 0.0914, "CU": 0.088, "EW": 0.0871, "FU": 0.0861, "NK": 0.0861, "UI": 0.0859, "VA": 0.0852, "PT": 0.0838, "YE": 0.0834, "OB": 0.0827, "YS": 0.0825, "SC": 0.0806, "OI": 0.0806, "AW": 0.0795, "CR": 0.0792, "CL": 0.0783, "EG": 0.0772, "CC": 0.0769, "BR": 0.0767, "IB": 0.0724, "BA": 0.0719, "JA": 0.0714, "MS": 0.0712, "RU": 0.071, "XP": 0.0689, "HU": 0.0682, "LT": 0.0634, "UD": 0.0629, "NV": 0.0595, "TW": 0.0588, "RG": 0.0586, "MM": 0.0586, "IK": 0.0583, "UM": 0.0581, "UB": 0.057, "DR": 0.0565, "JE": 0.056, "VO": 0.0558, "LK": 0.0556, "GS": 0.0549, "LS": 0.0549, "DU": 0.0544, "NF": 0.0528, "OC": 0.0519, "RF": 0.0517, "DD": 0.0501, "RV": 0.0501, "MB": 0.0473, "JU": 0.0471, "KH": 0.0462, "YT": 0.0455, "EQ": 0.0443, "BJ": 0.0436, "PS": 0.0427, "PU": 0.0416, "IP": 0.0416, "YD": 0.0404, "NU": 0.0395, "HR": 0.0386, "YI": 0.0384, "XC": 0.0377, "BS": 0.0372, "GU": 0.037, "DL": 0.0368, "JO": 0.0361, "EH": 0.0351, "XT": 0.0351, "SL": 0.0345, "SM": 0.034, "FL": 0.0319, "SW": 0.031, "SF": 0.031, "PY": 0.031, "EO": 0.0305, "RP": 0.0301, "DG": 0.0296, "WR": 0.0296, "LW": 0.0292, "RH": 0.0289, "GN": 0.0289, "RK": 0.028, "LV": 0.0271, "BT": 0.0266, "OG": 0.0264, "PH": 0.0255, "OA": 0.0253, "KS": 0.0248, "TC": 0.0239, "SK": 0.0237, "DV": 0.0237, "ZZ": 0.0237, "OE": 0.0234, "XI": 0.023, "CQ": 0.023, "OH": 0.0227, "ZY": 0.0225, "DM": 0.0218, "TF": 0.0214, "EK": 0.0202, "WS": 0.0202, "GB": 0.0202, "HM": 0.0195, "RW": 0.0193, "LM": 0.0184, "HY": 0.0181, "TN": 0.0175, "OY": 0.0172, "XA": 0.017, "NQ": 0.017, "NJ": 0.017, "TM": 0.0163, "YB": 0.0161, "SY": 0.0156, "XE": 0.0154, "IX": 0.0154, "WL": 0.0152, "DN": 0.0149, "SB": 0.0136, "MF": 0.0136, "RB": 0.0131, "UF": 0.0126, "HB": 0.0122, "EJ": 0.0117, "HL": 0.0108, "NX": 0.0108, "LP": 0.0106, "XX": 0.0106, "ZE": 0.0106, "II": 0.0101, "YM": 0.0099, "EB": 0.0096, "FY": 0.0096, "GT": 0.0094, "KF": 0.0092, "SG": 0.009, "YR": 0.009, "TZ": 0.0085, "ZW": 0.0085, "GG": 0.0083, "NW": 0.008, "SD": 0.0078, "NH": 0.0071, "LC": 0.0071, "LR": 0.0067, "HS": 0.0064, "LN": 0.0055, "SN": 0.0055, "GY": 0.0055, "GM": 0.0053, "AZ": 0.0053, "YN": 0.0051, "LG": 0.0048, "IU": 0.0048, "MN": 0.0048, "NP": 0.0048, "YL": 0.0046, "DF": 0.0046, "DW": 0.0044, "KA": 0.0041, "KY": 0.0041, "XV": 0.0041, "EU": 0.0041, "NR": 0.0039, "KW": 0.0037, "AO": 0.0037, "ML": 0.0034, "KL": 0.0034, "WK": 0.0032, "XL": 0.0032, "CS": 0.003, "ZI": 0.0028, "NM": 0.0028, "XH": 0.0025, "HF": 0.0023, "HN": 0.0021, "BB": 0.0021, "UO": 0.0021, "HD": 0.0021, "YF": 0.0018, "MT": 0.0018, "VY": 0.0018, "OX": 0.0016, "SR": 0.0016, "BH": 0.0016, "VU": 0.0014, "HW": 0.0014, "IQ": 0.0014, "YP": 0.0014, "OQ": 0.0014, "AE": 0.0014, "AH": 0.0014, "WD": 0.0014, "SQ": 0.0011, "WF": 0.0011, "AX": 0.0011, "UY": 0.0011, "YA": 0.0011, "IH": 0.0011}
FLOOR = 0.005


def clean(text):
    return ''.join(c for c in text.upper() if c in ALPHA)


# Decryption is one forward pass: the priming key covers the first
# len(primer) positions, and every position after that reuses the
# plaintext letter recovered exactly len(primer) steps earlier.
def autokey_decrypt(ciphertext, primer):
    n = len(ciphertext)
    key = [None] * n
    for i, k in enumerate(primer):
        key[i] = k
    out = []
    for i, c in enumerate(ciphertext):
        p = ALPHA[(ALPHA.index(c) - ALPHA.index(key[i])) % 26]
        out.append(p)
        if i + len(primer) < n:
            key[i + len(primer)] = p
    return ''.join(out)


def score_text(text):
    return sum(math.log(BIGRAMS.get(text[i - 1:i + 1], FLOOR)) for i in range(1, len(text)))


def break_autokey(ciphertext, max_len=MAX_LEN):
    best_primer, best_score, best_plain = None, float('-inf'), ''
    for length in range(1, max_len + 1):
        for n in range(26 ** length):
            digits = []
            x = n
            for _ in range(length):
                digits.append(x % 26)
                x //= 26
            primer = ''.join(ALPHA[d] for d in reversed(digits))
            plain = autokey_decrypt(ciphertext, primer)
            score = score_text(plain)
            if score > best_score:
                best_primer, best_score, best_plain = primer, score, plain
    return {'primer': best_primer, 'score': best_score, 'plaintext': best_plain}


if __name__ == '__main__':
    ciphertext = "ZVPDNASKYRQSTFGZGLQFMIEHCIHXYMDHZKDDBVTRGMLRGDHSBCKAMPKUMKAFQBNWMDRQHHSAEQTBMBNBRWPRVEHMCEEFGBXPXTYARETBUXYXFWDVCSFFRCQHUGWAVVLWFZFXEOWMLXERQFOGTINMIUMIYIFXEOWMLOCWRQDDNAFLFDSOYWWOULLDMUYRKFPHYPVLESEYHVVIDTMCUEENNLTNCDXRYRSUKLGQCXCACPSGQWVLKMCDGFGJCOVPCKSJQXPDWXSTQXEOWMLAZXMMDFSJSMWFRATZRLFYKELQKVYBTVKFTXGAMVRFMJWMKWOIZLAIVOKUGZYURVKCBUWVVVVXZYCXSALBNYTZRSEVRGWMFNWMLAPVITMIHTIKALTBKKTAKFGDMJXZRCCSGZPVTGCNHRPAPRKLMCKPQXEOWMLJOVYLPJEDPJCNYSQWKVWRJFIGMPVMAVFNNYHBKZXSJJIQJIISGZRTMMLIREGPZOIYECSEQJWKCRVFLCXIRYYFSGCDZWUWXMKRURGYMFYYSAKROTESSEUAEXLUMXHTYPLTJAAPPGHYVGQKGVKFXEOWMLJOGPBTVJNTKXTGKQCHKZKYYSHXYCAMIQYTVLMPVTEPKHPHTFXKTFXJXYCGEJR"

    result = break_autokey(ciphertext)
    print('Recovered priming key:', result['primer'])
    print(f"Best fit score: {result['score']:.2f}")
    print()
    print('Recovered plaintext:')
    print(result['plaintext'])

Running this against the same GOLD-primed passage from the worked example above produces:

Recovered priming key: GOLD
Best fit score: -662.23

Recovered plaintext:
THEAUTOKEYCIPHERREMOVESTHEPERIODICPATTERNTHATKASISKIEXAMINATIONDEPENDSONBYFOLDINGTHEPLAINTEXTITSELFINTOTHEKEYSTREAMONCETHESHORTPRIMINGKEYRUNSOUTBUTTHATPRIMINGKEYISSTILLUSUALLYONLYAHANDFULOFLETTERSLONGWHICHMEANSANATTACKERWHODOESNOTKNOWITCANSIMPLYTRYEVERYPOSSIBLEPRIMINGKEYUPTOSOMEREASONABLELENGTHDECRYPTTHEENTIREMESSAGEWITHEACHGUESSANDSCORETHERESULTAGAINSTREALENGLISHLETTERPAIRSTATISTICSASINGLEWRONGLETTERANYWHEREINTHEPRIMINGKEYDERAILSEVERYSUBSEQUENTLETTERTHROUGHTHESELFREFERENCINGKEYCHAINSOALMOSTEVERYWRONGGUESSLOOKSLIKEMEANINGLESSNOISEALMOSTIMMEDIATELYWHILETHECORRECTPRIMINGKEYDECRYPTSPERFECTLYFROMTHEVERYFIRSTLETTERALLTHEWAYTOTHEVERYLAST

That matches the visualizer’s own result exactly, including the score. (This is pure Python with no array-level optimization, exhaustively testing all 475,254 candidates, so expect it to take a minute or so to run to completion, unlike this site’s other, smaller brute-force breakers.)

For Fun: The Same Thing in About a Dozen Lines

Same spirit as the Caesar Cipher Breaker’s 5-line one-liner and the Enigma breaker’s golfed tier: not something to learn the algorithm from, just a demonstration of how compactly this reduces. The bounded search, the self-extending decrypt, and the scoring all collapse into about a dozen lines, using the exact same ~390-entry BIGRAMS table as the readable version above.

import math
A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
BIGRAMS = {"HE": 3.5637, "TH": 3.3246, "ER": 2.706, "IN": 2.4173, "AN": 1.9097, "RE": 1.7599, "HA": 1.5132, "EN": 1.4942, "ON": 1.4774, "OU": 1.4742, "AT": 1.3883, "ND": 1.3435, "ED": 1.2794, "TO": 1.2408, "IS": 1.1669, "IT": 1.1623, "AS": 1.1473, "NG": 1.1462, "HI": 1.0715, "TE": 1.0665, "VE": 1.0235, "AR": 0.9992, "BE": 0.9838, "OF": 0.9627, "NE": 0.9617, "OR": 0.931, "SE": 0.9101, "ST": 0.8866, "ES": 0.8682, "LE": 0.8646, "NT": 0.8177, "NO": 0.8058, "TI": 0.7947, "EL": 0.7821, "LL": 0.7803, "ME": 0.7578, "LI": 0.7559, "EA": 0.75, "CO": 0.7481, "SH": 0.699, "AL": 0.6925, "OT": 0.6475, "WA": 0.6392, "HO": 0.6282, "UR": 0.6195, "ET": 0.617, "DE": 0.6119, "WI": 0.5965, "CE": 0.5951, "LY": 0.5938, "AD": 0.5795, "WH": 0.5568, "CH": 0.5531, "IO": 0.5481, "OM": 0.5276, "FO": 0.5276, "SI": 0.5106, "RI": 0.501, "YO": 0.4978, "DI": 0.4681, "RS": 0.4594, "IL": 0.4559, "RO": 0.4546, "EE": 0.4546, "MA": 0.4532, "SS": 0.4481, "US": 0.4474, "UT": 0.4426, "SO": 0.4332, "OW": 0.4327, "WE": 0.4213, "UL": 0.4201, "PE": 0.4183, "IM": 0.4061, "EC": 0.3951, "LD": 0.3948, "NC": 0.3925, "AI": 0.3723, "RA": 0.3703, "GH": 0.3696, "TA": 0.3682, "AB": 0.368, "IC": 0.3618, "MI": 0.3602, "SU": 0.3576, "IE": 0.3565, "NS": 0.3535, "IR": 0.353, "LA": 0.3443, "UN": 0.3397, "EV": 0.3381, "CA": 0.3374, "EM": 0.3264, "GE": 0.3252, "PR": 0.325, "AV": 0.323, "SA": 0.3188, "LO": 0.3184, "RY": 0.3151, "CT": 0.3151, "ID": 0.3131, "AM": 0.3122, "MO": 0.3112, "OO": 0.3108, "RT": 0.3078, "AY": 0.286, "TT": 0.2807, "AC": 0.2729, "EY": 0.2724, "OS": 0.2708, "FE": 0.2704, "MR": 0.2669, "BU": 0.2651, "EI": 0.2522, "KE": 0.2517, "DA": 0.2501, "PO": 0.2451, "OL": 0.2446, "UC": 0.2442, "BL": 0.2439, "WO": 0.2416, "PA": 0.2364, "TR": 0.2297, "HT": 0.2281, "IG": 0.2194, "DO": 0.218, "IV": 0.2145, "PL": 0.2104, "NI": 0.2083, "VI": 0.206, "UG": 0.2051, "RD": 0.2035, "AG": 0.2017, "FI": 0.2012, "FR": 0.1994, "MY": 0.1941, "AP": 0.1927, "FA": 0.1877, "NN": 0.1867, "TY": 0.1844, "IZ": 0.1838, "TU": 0.1812, "IF": 0.1766, "EX": 0.1759, "GA": 0.1716, "MP": 0.1714, "IA": 0.1702, "MU": 0.1693, "BY": 0.1681, "TL": 0.1681, "SP": 0.1663, "PP": 0.1642, "RC": 0.1619, "EF": 0.1596, "ZA": 0.1539, "BO": 0.1537, "GR": 0.1525, "LF": 0.1498, "QU": 0.1488, "OP": 0.1477, "EP": 0.1463, "RN": 0.1413, "NY": 0.1401, "FF": 0.1397, "AK": 0.139, "NA": 0.1383, "RR": 0.1374, "GI": 0.1309, "AU": 0.1298, "GO": 0.1293, "OV": 0.1279, "OD": 0.1279, "CI": 0.1263, "TS": 0.1245, "CY": 0.1217, "KI": 0.1171, "GL": 0.1169, "RL": 0.1137, "LU": 0.1116, "KN": 0.1112, "WN": 0.1107, "AF": 0.1103, "UA": 0.1075, "UP": 0.1075, "CK": 0.1045, "BI": 0.1018, "FT": 0.1015, "PI": 0.1008, "DY": 0.0983, "RM": 0.0976, "DS": 0.0942, "UE": 0.0935, "OK": 0.0919, "NL": 0.0914, "CU": 0.088, "EW": 0.0871, "FU": 0.0861, "NK": 0.0861, "UI": 0.0859, "VA": 0.0852, "PT": 0.0838, "YE": 0.0834, "OB": 0.0827, "YS": 0.0825, "SC": 0.0806, "OI": 0.0806, "AW": 0.0795, "CR": 0.0792, "CL": 0.0783, "EG": 0.0772, "CC": 0.0769, "BR": 0.0767, "IB": 0.0724, "BA": 0.0719, "JA": 0.0714, "MS": 0.0712, "RU": 0.071, "XP": 0.0689, "HU": 0.0682, "LT": 0.0634, "UD": 0.0629, "NV": 0.0595, "TW": 0.0588, "RG": 0.0586, "MM": 0.0586, "IK": 0.0583, "UM": 0.0581, "UB": 0.057, "DR": 0.0565, "JE": 0.056, "VO": 0.0558, "LK": 0.0556, "GS": 0.0549, "LS": 0.0549, "DU": 0.0544, "NF": 0.0528, "OC": 0.0519, "RF": 0.0517, "DD": 0.0501, "RV": 0.0501, "MB": 0.0473, "JU": 0.0471, "KH": 0.0462, "YT": 0.0455, "EQ": 0.0443, "BJ": 0.0436, "PS": 0.0427, "PU": 0.0416, "IP": 0.0416, "YD": 0.0404, "NU": 0.0395, "HR": 0.0386, "YI": 0.0384, "XC": 0.0377, "BS": 0.0372, "GU": 0.037, "DL": 0.0368, "JO": 0.0361, "EH": 0.0351, "XT": 0.0351, "SL": 0.0345, "SM": 0.034, "FL": 0.0319, "SW": 0.031, "SF": 0.031, "PY": 0.031, "EO": 0.0305, "RP": 0.0301, "DG": 0.0296, "WR": 0.0296, "LW": 0.0292, "RH": 0.0289, "GN": 0.0289, "RK": 0.028, "LV": 0.0271, "BT": 0.0266, "OG": 0.0264, "PH": 0.0255, "OA": 0.0253, "KS": 0.0248, "TC": 0.0239, "SK": 0.0237, "DV": 0.0237, "ZZ": 0.0237, "OE": 0.0234, "XI": 0.023, "CQ": 0.023, "OH": 0.0227, "ZY": 0.0225, "DM": 0.0218, "TF": 0.0214, "EK": 0.0202, "WS": 0.0202, "GB": 0.0202, "HM": 0.0195, "RW": 0.0193, "LM": 0.0184, "HY": 0.0181, "TN": 0.0175, "OY": 0.0172, "XA": 0.017, "NQ": 0.017, "NJ": 0.017, "TM": 0.0163, "YB": 0.0161, "SY": 0.0156, "XE": 0.0154, "IX": 0.0154, "WL": 0.0152, "DN": 0.0149, "SB": 0.0136, "MF": 0.0136, "RB": 0.0131, "UF": 0.0126, "HB": 0.0122, "EJ": 0.0117, "HL": 0.0108, "NX": 0.0108, "LP": 0.0106, "XX": 0.0106, "ZE": 0.0106, "II": 0.0101, "YM": 0.0099, "EB": 0.0096, "FY": 0.0096, "GT": 0.0094, "KF": 0.0092, "SG": 0.009, "YR": 0.009, "TZ": 0.0085, "ZW": 0.0085, "GG": 0.0083, "NW": 0.008, "SD": 0.0078, "NH": 0.0071, "LC": 0.0071, "LR": 0.0067, "HS": 0.0064, "LN": 0.0055, "SN": 0.0055, "GY": 0.0055, "GM": 0.0053, "AZ": 0.0053, "YN": 0.0051, "LG": 0.0048, "IU": 0.0048, "MN": 0.0048, "NP": 0.0048, "YL": 0.0046, "DF": 0.0046, "DW": 0.0044, "KA": 0.0041, "KY": 0.0041, "XV": 0.0041, "EU": 0.0041, "NR": 0.0039, "KW": 0.0037, "AO": 0.0037, "ML": 0.0034, "KL": 0.0034, "WK": 0.0032, "XL": 0.0032, "CS": 0.003, "ZI": 0.0028, "NM": 0.0028, "XH": 0.0025, "HF": 0.0023, "HN": 0.0021, "BB": 0.0021, "UO": 0.0021, "HD": 0.0021, "YF": 0.0018, "MT": 0.0018, "VY": 0.0018, "OX": 0.0016, "SR": 0.0016, "BH": 0.0016, "VU": 0.0014, "HW": 0.0014, "IQ": 0.0014, "YP": 0.0014, "OQ": 0.0014, "AE": 0.0014, "AH": 0.0014, "WD": 0.0014, "SQ": 0.0011, "WF": 0.0011, "AX": 0.0011, "UY": 0.0011, "YA": 0.0011, "IH": 0.0011}
FLOOR = 0.005

score = lambda t: sum(math.log(BIGRAMS.get(t[i-1:i+1], FLOOR)) for i in range(1, len(t)))

def dec(c, k):
    key, out = list(k) + [None] * (len(c) - len(k)), []
    for i, ch in enumerate(c):
        p = A[(A.index(ch) - A.index(key[i])) % 26]
        out.append(p)
        if i + len(k) < len(c): key[i + len(k)] = p
    return ''.join(out)

primers = lambda maxlen: (''.join(A[(n // 26 ** j) % 26] for j in range(L - 1, -1, -1)) for L in range(1, maxlen + 1) for n in range(26 ** L))

break_autokey = lambda c, maxlen=4: max(((k, dec(c, k)) for k in primers(maxlen)), key=lambda x: score(x[1]))

if __name__ == '__main__':
    ciphertext = "ZVPDNASKYRQSTFGZGLQFMIEHCIHXYMDHZKDDBVTRGMLRGDHSBCKAMPKUMKAFQBNWMDRQHHSAEQTBMBNBRWPRVEHMCEEFGBXPXTYARETBUXYXFWDVCSFFRCQHUGWAVVLWFZFXEOWMLXERQFOGTINMIUMIYIFXEOWMLOCWRQDDNAFLFDSOYWWOULLDMUYRKFPHYPVLESEYHVVIDTMCUEENNLTNCDXRYRSUKLGQCXCACPSGQWVLKMCDGFGJCOVPCKSJQXPDWXSTQXEOWMLAZXMMDFSJSMWFRATZRLFYKELQKVYBTVKFTXGAMVRFMJWMKWOIZLAIVOKUGZYURVKCBUWVVVVXZYCXSALBNYTZRSEVRGWMFNWMLAPVITMIHTIKALTBKKTAKFGDMJXZRCCSGZPVTGCNHRPAPRKLMCKPQXEOWMLJOVYLPJEDPJCNYSQWKVWRJFIGMPVMAVFNNYHBKZXSJJIQJIISGZRTMMLIREGPZOIYECSEQJWKCRVFLCXIRYYFSGCDZWUWXMKRURGYMFYYSAKROTESSEUAEXLUMXHTYPLTJAAPPGHYVGQKGVKFXEOWMLJOGPBTVJNTKXTGKQCHKZKYYSHXYCAMIQYTVLMPVTEPKHPHTFXKTFXJXYCGEJR"
    key, plain = break_autokey(ciphertext)
    print('Recovered priming key:', key)
    print(f"Best fit score: {score(plain):.2f}")
    print()
    print('Recovered plaintext:')
    print(plain)

Two things make this compress so cleanly. primers is a generator expression with two for clauses: for each candidate length L, it walks n from 0 to 26^L − 1 and converts n to base 26 on the fly via (n // 26**j) % 26 for each digit position j, from most significant down to least. That exactly mirrors the readable version’s explicit digit-extraction loop, just as one expression. dec stays a def rather than a lambda for the same reason Enigma’s step did: it mutates the key list as it goes and needs a for loop with a conditional append, which are statements, not expressions. break_autokey’s max(...) over every (primer, decrypted) pair primers produces replaces the readable version’s explicit best-tracking loop. Since it walks primer lengths from shortest to longest and, within each length, n from 0 upward, ties would resolve to whichever candidate comes first in that order. This particular ciphertext has no ties to break, though: GOLD wins outright.

Going Even Further: Parsing the Digram Table Instead of Writing It Out

Every golfed script on this site so far has still paid for its digram table in full. {"HE": 3.5637, "TH": 3.3246, ...} costs a pair of quote marks, a colon, and a comma for every one of its roughly 390 entries, on top of the letters and numbers that actually carry the information. The quotes, colons, and commas are pure punctuation overhead. A bigram code is always exactly two uppercase letters, and a frequency is always exactly digits and an optional decimal point. So the two are already self-delimiting without any separator at all. Concatenate every entry directly, HE3.5637TH3.3246ER2.706..., and a single regular expression can split it back apart:

import re
from math import log
A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
G = {k: float(v) for k, v in re.findall(r'([A-Z]{2})([\d.]+)', 'HE3.5637TH3.3246ER2.706IN2.4173AN1.9097RE1.7599HA1.5132EN1.4942ON1.4774OU1.4742AT1.3883ND1.3435ED1.2794TO1.2408IS1.1669IT1.1623AS1.1473NG1.1462HI1.0715TE1.0665VE1.0235AR0.9992BE0.9838OF0.9627NE0.9617OR0.931SE0.9101ST0.8866ES0.8682LE0.8646NT0.8177NO0.8058TI0.7947EL0.7821LL0.7803ME0.7578LI0.7559EA0.75CO0.7481SH0.699AL0.6925OT0.6475WA0.6392HO0.6282UR0.6195ET0.617DE0.6119WI0.5965CE0.5951LY0.5938AD0.5795WH0.5568CH0.5531IO0.5481OM0.5276FO0.5276SI0.5106RI0.501YO0.4978DI0.4681RS0.4594IL0.4559RO0.4546EE0.4546MA0.4532SS0.4481US0.4474UT0.4426SO0.4332OW0.4327WE0.4213UL0.4201PE0.4183IM0.4061EC0.3951LD0.3948NC0.3925AI0.3723RA0.3703GH0.3696TA0.3682AB0.368IC0.3618MI0.3602SU0.3576IE0.3565NS0.3535IR0.353LA0.3443UN0.3397EV0.3381CA0.3374EM0.3264GE0.3252PR0.325AV0.323SA0.3188LO0.3184RY0.3151CT0.3151ID0.3131AM0.3122MO0.3112OO0.3108RT0.3078AY0.286TT0.2807AC0.2729EY0.2724OS0.2708FE0.2704MR0.2669BU0.2651EI0.2522KE0.2517DA0.2501PO0.2451OL0.2446UC0.2442BL0.2439WO0.2416PA0.2364TR0.2297HT0.2281IG0.2194DO0.218IV0.2145PL0.2104NI0.2083VI0.206UG0.2051RD0.2035AG0.2017FI0.2012FR0.1994MY0.1941AP0.1927FA0.1877NN0.1867TY0.1844IZ0.1838TU0.1812IF0.1766EX0.1759GA0.1716MP0.1714IA0.1702MU0.1693BY0.1681TL0.1681SP0.1663PP0.1642RC0.1619EF0.1596ZA0.1539BO0.1537GR0.1525LF0.1498QU0.1488OP0.1477EP0.1463RN0.1413NY0.1401FF0.1397AK0.139NA0.1383RR0.1374GI0.1309AU0.1298GO0.1293OV0.1279OD0.1279CI0.1263TS0.1245CY0.1217KI0.1171GL0.1169RL0.1137LU0.1116KN0.1112WN0.1107AF0.1103UA0.1075UP0.1075CK0.1045BI0.1018FT0.1015PI0.1008DY0.0983RM0.0976DS0.0942UE0.0935OK0.0919NL0.0914CU0.088EW0.0871FU0.0861NK0.0861UI0.0859VA0.0852PT0.0838YE0.0834OB0.0827YS0.0825SC0.0806OI0.0806AW0.0795CR0.0792CL0.0783EG0.0772CC0.0769BR0.0767IB0.0724BA0.0719JA0.0714MS0.0712RU0.071XP0.0689HU0.0682LT0.0634UD0.0629NV0.0595TW0.0588RG0.0586MM0.0586IK0.0583UM0.0581UB0.057DR0.0565JE0.056VO0.0558LK0.0556GS0.0549LS0.0549DU0.0544NF0.0528OC0.0519RF0.0517DD0.0501RV0.0501MB0.0473JU0.0471KH0.0462YT0.0455EQ0.0443BJ0.0436PS0.0427PU0.0416IP0.0416YD0.0404NU0.0395HR0.0386YI0.0384XC0.0377BS0.0372GU0.037DL0.0368JO0.0361EH0.0351XT0.0351SL0.0345SM0.034FL0.0319SW0.031SF0.031PY0.031EO0.0305RP0.0301DG0.0296WR0.0296LW0.0292RH0.0289GN0.0289RK0.028LV0.0271BT0.0266OG0.0264PH0.0255OA0.0253KS0.0248TC0.0239SK0.0237DV0.0237ZZ0.0237OE0.0234XI0.023CQ0.023OH0.0227ZY0.0225DM0.0218TF0.0214EK0.0202WS0.0202GB0.0202HM0.0195RW0.0193LM0.0184HY0.0181TN0.0175OY0.0172XA0.017NQ0.017NJ0.017TM0.0163YB0.0161SY0.0156XE0.0154IX0.0154WL0.0152DN0.0149SB0.0136MF0.0136RB0.0131UF0.0126HB0.0122EJ0.0117HL0.0108NX0.0108LP0.0106XX0.0106ZE0.0106II0.0101YM0.0099EB0.0096FY0.0096GT0.0094KF0.0092SG0.009YR0.009TZ0.0085ZW0.0085GG0.0083NW0.008SD0.0078NH0.0071LC0.0071LR0.0067HS0.0064LN0.0055SN0.0055GY0.0055GM0.0053AZ0.0053YN0.0051LG0.0048IU0.0048MN0.0048NP0.0048YL0.0046DF0.0046DW0.0044KA0.0041KY0.0041XV0.0041EU0.0041NR0.0039KW0.0037AO0.0037ML0.0034KL0.0034WK0.0032XL0.0032CS0.003ZI0.0028NM0.0028XH0.0025HF0.0023HN0.0021BB0.0021UO0.0021HD0.0021YF0.0018MT0.0018VY0.0018OX0.0016SR0.0016BH0.0016VU0.0014HW0.0014IQ0.0014YP0.0014OQ0.0014AE0.0014AH0.0014WD0.0014SQ0.0011WF0.0011AX0.0011UY0.0011YA0.0011IH0.0011')}
F = 0.005
S = lambda t: sum(log(G.get(t[i-1:i+1], F)) for i in range(1, len(t)))

def D(c, k):
    K = list(k) + [None] * (len(c) - len(k)); o = []
    for i, ch in enumerate(c):
        p = A[(A.index(ch) - A.index(K[i])) % 26]; o.append(p)
        if i + len(k) < len(c): K[i + len(k)] = p
    return ''.join(o)

P = lambda m: (''.join(A[(n // 26 ** j) % 26] for j in range(L - 1, -1, -1)) for L in range(1, m + 1) for n in range(26 ** L))
X = lambda c, m=4: max(((k, D(c, k)) for k in P(m)), key=lambda x: S(x[1]))

if __name__ == '__main__':
    c = "ZVPDNASKYRQSTFGZGLQFMIEHCIHXYMDHZKDDBVTRGMLRGDHSBCKAMPKUMKAFQBNWMDRQHHSAEQTBMBNBRWPRVEHMCEEFGBXPXTYARETBUXYXFWDVCSFFRCQHUGWAVVLWFZFXEOWMLXERQFOGTINMIUMIYIFXEOWMLOCWRQDDNAFLFDSOYWWOULLDMUYRKFPHYPVLESEYHVVIDTMCUEENNLTNCDXRYRSUKLGQCXCACPSGQWVLKMCDGFGJCOVPCKSJQXPDWXSTQXEOWMLAZXMMDFSJSMWFRATZRLFYKELQKVYBTVKFTXGAMVRFMJWMKWOIZLAIVOKUGZYURVKCBUWVVVVXZYCXSALBNYTZRSEVRGWMFNWMLAPVITMIHTIKALTBKKTAKFGDMJXZRCCSGZPVTGCNHRPAPRKLMCKPQXEOWMLJOVYLPJEDPJCNYSQWKVWRJFIGMPVMAVFNNYHBKZXSJJIQJIISGZRTMMLIREGPZOIYECSEQJWKCRVFLCXIRYYFSGCDZWUWXMKRURGYMFYYSAKROTESSEUAEXLUMXHTYPLTJAAPPGHYVGQKGVKFXEOWMLJOGPBTVJNTKXTGKQCHKZKYYSHXYCAMIQYTVLMPVTEPKHPHTFXKTFXJXYCGEJR"
    k, p = X(c)
    print(f'Recovered priming key: {k}\nBest fit score: {S(p):.2f}\n\nRecovered plaintext:\n{p}')

The regex ([A-Z]{2})([\d.]+) does the actual unpacking: it walks the concatenated string left to right. At every position it greedily grabs exactly two uppercase letters, followed by as many digit-or-dot characters as follow before the next letter starts. Because a bigram code is always two letters and a frequency never contains a letter, the two capture groups never have to guess where one entry ends and the next begins. The character classes alone are enough to split the string back into the same roughly 390 (code, frequency) pairs the dict literal spelled out explicitly. {k: float(v) for k, v in re.findall(...)} rebuilds the dictionary from them in one comprehension. It’s verified to parse into the exact same table and reach the exact same result as every other version on this page: primer GOLD, score -662.23, identical recovered plaintext. From there the rest is the same trick taken further: dec/primers/break_autokey become D/P/X, key/out become K/o, and the body drops to one-space indentation, none of which changes the algorithm, only how many characters it takes to say it.

A Genuinely Faster Version: Precomputed Tables and Multiprocessing

The two versions above optimize for fewer characters, not less time. The readable script above takes on the order of a minute and a half to search all 475,254 candidates. That’s because it re-derives a fresh len(ciphertext)-sized key array and calls ALPHA.index() (an O(26) scan) for every single letter of every single candidate. Someone who actually wants the answer back quickly needs different optimizations entirely, stacking an algorithmic speedup with a parallel one:

  • A circular key buffer instead of a full-length array. The recurrence only ever needs plaintext[i − L]. Since i − L ≡ i (mod L), a buffer of size L indexed by i % L holds exactly that value at exactly the moment it’s needed. It was written there L steps earlier and hasn’t been touched since. That drops the per-candidate working memory from O(n) to O(L).
  • Precomputed lookup tables instead of repeated ALPHA.index() calls. DECRYPT_TABLE[c][k] replaces (ALPHA.index(cipher_letter) - ALPHA.index(key_letter)) % 26 with a single list index. The digram table is flattened into LOG_BIGRAM, a 676-entry list indexed by prev * 26 + cur, so scoring a letter pair is also one list index instead of a dictionary lookup keyed by a freshly sliced two-character string.
  • Spreading the search across every CPU core. multiprocessing.Pool hands each core its own share of the 475,254 candidates via imap_unordered, with chunksize=1000 so workers pull batches of primers at a time rather than negotiating over one candidate at a time. The interprocess overhead would otherwise dwarf the actual work.
# autokey_breaker_fast.py
import math
import itertools
from functools import partial
from multiprocessing import Pool, cpu_count

ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
MAX_LEN = 4

# Full bigram frequency table (same as original)
BIGRAMS = {
    "HE": 3.5637, "TH": 3.3246, "ER": 2.706, "IN": 2.4173, "AN": 1.9097,
    "RE": 1.7599, "HA": 1.5132, "EN": 1.4942, "ON": 1.4774, "OU": 1.4742,
    "AT": 1.3883, "ND": 1.3435, "ED": 1.2794, "TO": 1.2408, "IS": 1.1669,
    "IT": 1.1623, "AS": 1.1473, "NG": 1.1462, "HI": 1.0715, "TE": 1.0665,
    "VE": 1.0235, "AR": 0.9992, "BE": 0.9838, "OF": 0.9627, "NE": 0.9617,
    "OR": 0.931, "SE": 0.9101, "ST": 0.8866, "ES": 0.8682, "LE": 0.8646,
    "NT": 0.8177, "NO": 0.8058, "TI": 0.7947, "EL": 0.7821, "LL": 0.7803,
    "ME": 0.7578, "LI": 0.7559, "EA": 0.75, "CO": 0.7481, "SH": 0.699,
    "AL": 0.6925, "OT": 0.6475, "WA": 0.6392, "HO": 0.6282, "UR": 0.6195,
    "ET": 0.617, "DE": 0.6119, "WI": 0.5965, "CE": 0.5951, "LY": 0.5938,
    "AD": 0.5795, "WH": 0.5568, "CH": 0.5531, "IO": 0.5481, "OM": 0.5276,
    "FO": 0.5276, "SI": 0.5106, "RI": 0.501, "YO": 0.4978, "DI": 0.4681,
    "RS": 0.4594, "IL": 0.4559, "RO": 0.4546, "EE": 0.4546, "MA": 0.4532,
    "SS": 0.4481, "US": 0.4474, "UT": 0.4426, "SO": 0.4332, "OW": 0.4327,
    "WE": 0.4213, "UL": 0.4201, "PE": 0.4183, "IM": 0.4061, "EC": 0.3951,
    "LD": 0.3948, "NC": 0.3925, "AI": 0.3723, "RA": 0.3703, "GH": 0.3696,
    "TA": 0.3682, "AB": 0.368, "IC": 0.3618, "MI": 0.3602, "SU": 0.3576,
    "IE": 0.3565, "NS": 0.3535, "IR": 0.353, "LA": 0.3443, "UN": 0.3397,
    "EV": 0.3381, "CA": 0.3374, "EM": 0.3264, "GE": 0.3252, "PR": 0.325,
    "AV": 0.323, "SA": 0.3188, "LO": 0.3184, "RY": 0.3151, "CT": 0.3151,
    "ID": 0.3131, "AM": 0.3122, "MO": 0.3112, "OO": 0.3108, "RT": 0.3078,
    "AY": 0.286, "TT": 0.2807, "AC": 0.2729, "EY": 0.2724, "OS": 0.2708,
    "FE": 0.2704, "MR": 0.2669, "BU": 0.2651, "EI": 0.2522, "KE": 0.2517,
    "DA": 0.2501, "PO": 0.2451, "OL": 0.2446, "UC": 0.2442, "BL": 0.2439,
    "WO": 0.2416, "PA": 0.2364, "TR": 0.2297, "HT": 0.2281, "IG": 0.2194,
    "DO": 0.218, "IV": 0.2145, "PL": 0.2104, "NI": 0.2083, "VI": 0.206,
    "UG": 0.2051, "RD": 0.2035, "AG": 0.2017, "FI": 0.2012, "FR": 0.1994,
    "MY": 0.1941, "AP": 0.1927, "FA": 0.1877, "NN": 0.1867, "TY": 0.1844,
    "IZ": 0.1838, "TU": 0.1812, "IF": 0.1766, "EX": 0.1759, "GA": 0.1716,
    "MP": 0.1714, "IA": 0.1702, "MU": 0.1693, "BY": 0.1681, "TL": 0.1681,
    "SP": 0.1663, "PP": 0.1642, "RC": 0.1619, "EF": 0.1596, "ZA": 0.1539,
    "BO": 0.1537, "GR": 0.1525, "LF": 0.1498, "QU": 0.1488, "OP": 0.1477,
    "EP": 0.1463, "RN": 0.1413, "NY": 0.1401, "FF": 0.1397, "AK": 0.139,
    "NA": 0.1383, "RR": 0.1374, "GI": 0.1309, "AU": 0.1298, "GO": 0.1293,
    "OV": 0.1279, "OD": 0.1279, "CI": 0.1263, "TS": 0.1245, "CY": 0.1217,
    "KI": 0.1171, "GL": 0.1169, "RL": 0.1137, "LU": 0.1116, "KN": 0.1112,
    "WN": 0.1107, "AF": 0.1103, "UA": 0.1075, "UP": 0.1075, "CK": 0.1045,
    "BI": 0.1018, "FT": 0.1015, "PI": 0.1008, "DY": 0.0983, "RM": 0.0976,
    "DS": 0.0942, "UE": 0.0935, "OK": 0.0919, "NL": 0.0914, "CU": 0.088,
    "EW": 0.0871, "FU": 0.0861, "NK": 0.0861, "UI": 0.0859, "VA": 0.0852,
    "PT": 0.0838, "YE": 0.0834, "OB": 0.0827, "YS": 0.0825, "SC": 0.0806,
    "OI": 0.0806, "AW": 0.0795, "CR": 0.0792, "CL": 0.0783, "EG": 0.0772,
    "CC": 0.0769, "BR": 0.0767, "IB": 0.0724, "BA": 0.0719, "JA": 0.0714,
    "MS": 0.0712, "RU": 0.071, "XP": 0.0689, "HU": 0.0682, "LT": 0.0634,
    "UD": 0.0629, "NV": 0.0595, "TW": 0.0588, "RG": 0.0586, "MM": 0.0586,
    "IK": 0.0583, "UM": 0.0581, "UB": 0.057, "DR": 0.0565, "JE": 0.056,
    "VO": 0.0558, "LK": 0.0556, "GS": 0.0549, "LS": 0.0549, "DU": 0.0544,
    "NF": 0.0528, "OC": 0.0519, "RF": 0.0517, "DD": 0.0501, "RV": 0.0501,
    "MB": 0.0473, "JU": 0.0471, "KH": 0.0462, "YT": 0.0455, "EQ": 0.0443,
    "BJ": 0.0436, "PS": 0.0427, "PU": 0.0416, "IP": 0.0416, "YD": 0.0404,
    "NU": 0.0395, "HR": 0.0386, "YI": 0.0384, "XC": 0.0377, "BS": 0.0372,
    "GU": 0.037, "DL": 0.0368, "JO": 0.0361, "EH": 0.0351, "XT": 0.0351,
    "SL": 0.0345, "SM": 0.034, "FL": 0.0319, "SW": 0.031, "SF": 0.031,
    "PY": 0.031, "EO": 0.0305, "RP": 0.0301, "DG": 0.0296, "WR": 0.0296,
    "LW": 0.0292, "RH": 0.0289, "GN": 0.0289, "RK": 0.028, "LV": 0.0271,
    "BT": 0.0266, "OG": 0.0264, "PH": 0.0255, "OA": 0.0253, "KS": 0.0248,
    "TC": 0.0239, "SK": 0.0237, "DV": 0.0237, "ZZ": 0.0237, "OE": 0.0234,
    "XI": 0.023, "CQ": 0.023, "OH": 0.0227, "ZY": 0.0225, "DM": 0.0218,
    "TF": 0.0214, "EK": 0.0202, "WS": 0.0202, "GB": 0.0202, "HM": 0.0195,
    "RW": 0.0193, "LM": 0.0184, "HY": 0.0181, "TN": 0.0175, "OY": 0.0172,
    "XA": 0.017, "NQ": 0.017, "NJ": 0.017, "TM": 0.0163, "YB": 0.0161,
    "SY": 0.0156, "XE": 0.0154, "IX": 0.0154, "WL": 0.0152, "DN": 0.0149,
    "SB": 0.0136, "MF": 0.0136, "RB": 0.0131, "UF": 0.0126, "HB": 0.0122,
    "EJ": 0.0117, "HL": 0.0108, "NX": 0.0108, "LP": 0.0106, "XX": 0.0106,
    "ZE": 0.0106, "II": 0.0101, "YM": 0.0099, "EB": 0.0096, "FY": 0.0096,
    "GT": 0.0094, "KF": 0.0092, "SG": 0.009, "YR": 0.009, "TZ": 0.0085,
    "ZW": 0.0085, "GG": 0.0083, "NW": 0.008, "SD": 0.0078, "NH": 0.0071,
    "LC": 0.0071, "LR": 0.0067, "HS": 0.0064, "LN": 0.0055, "SN": 0.0055,
    "GY": 0.0055, "GM": 0.0053, "AZ": 0.0053, "YN": 0.0051, "LG": 0.0048,
    "IU": 0.0048, "MN": 0.0048, "NP": 0.0048, "YL": 0.0046, "DF": 0.0046,
    "DW": 0.0044, "KA": 0.0041, "KY": 0.0041, "XV": 0.0041, "EU": 0.0041,
    "NR": 0.0039, "KW": 0.0037, "AO": 0.0037, "ML": 0.0034, "KL": 0.0034,
    "WK": 0.0032, "XL": 0.0032, "CS": 0.003, "ZI": 0.0028, "NM": 0.0028,
    "XH": 0.0025, "HF": 0.0023, "HN": 0.0021, "BB": 0.0021, "UO": 0.0021,
    "HD": 0.0021, "YF": 0.0018, "MT": 0.0018, "VY": 0.0018, "OX": 0.0016,
    "SR": 0.0016, "BH": 0.0016, "VU": 0.0014, "HW": 0.0014, "IQ": 0.0014,
    "YP": 0.0014, "OQ": 0.0014, "AE": 0.0014, "AH": 0.0014, "WD": 0.0014,
    "SQ": 0.0011, "WF": 0.0011, "AX": 0.0011, "UY": 0.0011, "YA": 0.0011,
    "IH": 0.0011
}
FLOOR = 0.005

# Precompute log of bigram scores as a flat 26×26 list
LOG_BIGRAM = [0.0] * (26 * 26)
for i, a in enumerate(ALPHA):
    for j, b in enumerate(ALPHA):
        LOG_BIGRAM[i * 26 + j] = math.log(BIGRAMS.get(a + b, FLOOR))

# Precompute (c - k) mod 26 for all ciphertext letters and key letters
DECRYPT_TABLE = [[(c - k) % 26 for k in range(26)] for c in range(26)]


def clean(text):
    return ''.join(c for c in text.upper() if c in ALPHA)


def evaluate_primer(primer, L, cipher, log_bigram, decrypt_table):
    """
    Evaluate a single priming key (tuple of L ints).
    Returns (score, primer) – the score is the sum of log-bigram probabilities.
    """
    n = len(cipher)
    # Circular buffer of length L; initialise with the primer
    buffer = list(primer)
    score = 0.0
    prev = -1  # no previous character yet

    # First L positions: key is the primer itself
    for i in range(L):
        p = decrypt_table[cipher[i]][primer[i]]
        buffer[i] = p
        if i > 0:
            score += log_bigram[prev * 26 + p]
        prev = p

    # Remaining positions: key is the plaintext letter L steps earlier
    for i in range(L, n):
        k = buffer[i % L]
        p = decrypt_table[cipher[i]][k]
        buffer[i % L] = p
        score += log_bigram[prev * 26 + p]
        prev = p

    return score, primer


def break_autokey(ciphertext):
    # Convert ciphertext to list of ints (0‑25)
    cipher = [ord(ch) - 65 for ch in clean(ciphertext)]
    n = len(cipher)
    best_score = -float('inf')
    best_primer = None

    # Use all available CPU cores
    with Pool(processes=cpu_count()) as pool:
        for L in range(1, MAX_LEN + 1):
            # Generate all priming keys of length L as tuples of ints
            primer_iter = itertools.product(range(26), repeat=L)
            # Bind fixed arguments
            func = partial(evaluate_primer, L=L, cipher=cipher,
                           log_bigram=LOG_BIGRAM, decrypt_table=DECRYPT_TABLE)
            # Evaluate in parallel; update the best result
            for score, primer in pool.imap_unordered(func, primer_iter, chunksize=1000):
                if score > best_score:
                    best_score = score
                    best_primer = primer

    # Convert best primer to a string
    primer_str = ''.join(ALPHA[i] for i in best_primer)

    # Decrypt the ciphertext once using the original (slow) function – it is called only once
    def autokey_decrypt(ciphertext, primer):
        n = len(ciphertext)
        key = [None] * n
        for i, k in enumerate(primer):
            key[i] = k
        out = []
        for i, c in enumerate(ciphertext):
            p = ALPHA[(ALPHA.index(c) - ALPHA.index(key[i])) % 26]
            out.append(p)
            if i + len(primer) < n:
                key[i + len(primer)] = p
        return ''.join(out)

    plaintext = autokey_decrypt(ciphertext, primer_str)

    return {
        'primer': primer_str,
        'score': best_score,
        'plaintext': plaintext
    }


if __name__ == '__main__':
    ciphertext = "ZVPDNASKYRQSTFGZGLQFMIEHCIHXYMDHZKDDBVTRGMLRGDHSBCKAMPKUMKAFQBNWMDRQHHSAEQTBMBNBRWPRVEHMCEEFGBXPXTYARETBUXYXFWDVCSFFRCQHUGWAVVLWFZFXEOWMLXERQFOGTINMIUMIYIFXEOWMLOCWRQDDNAFLFDSOYWWOULLDMUYRKFPHYPVLESEYHVVIDTMCUEENNLTNCDXRYRSUKLGQCXCACPSGQWVLKMCDGFGJCOVPCKSJQXPDWXSTQXEOWMLAZXMMDFSJSMWFRATZRLFYKELQKVYBTVKFTXGAMVRFMJWMKWOIZLAIVOKUGZYURVKCBUWVVVVXZYCXSALBNYTZRSEVRGWMFNWMLAPVITMIHTIKALTBKKTAKFGDMJXZRCCSGZPVTGCNHRPAPRKLMCKPQXEOWMLJOVYLPJEDPJCNYSQWKVWRJFIGMPVMAVFNNYHBKZXSJJIQJIISGZRTMMLIREGPZOIYECSEQJWKCRVFLCXIRYYFSGCDZWUWXMKRURGYMFYYSAKROTESSEUAEXLUMXHTYPLTJAAPPGHYVGQKGVKFXEOWMLJOGPBTVJNTKXTGKQCHKZKYYSHXYCAMIQYTVLMPVTEPKHPHTFXKTFXJXYCGEJR"

    result = break_autokey(ciphertext)
    print('Recovered priming key:', result['primer'])
    print(f"Best fit score: {result['score']:.2f}")
    print()
    print('Recovered plaintext:')
    print(result['plaintext'])

On this site’s own 16-core test machine, the readable version above took 114.34 seconds to search all 475,254 candidates. This version (same search, same bounds, same digram table) finished in 4.16 seconds, about 27x faster. That’s a combination of both optimizations working together. The circular buffer and precomputed tables cut the work each individual candidate costs, and multiprocessing spreads what’s left across every available core. So the exact speedup any given reader sees will scale with core count. Correctness is unaffected either way. It recovers the identical GOLD at score -662.23, matching every other version on this page letter for letter.

Interactive Visualizer

Try it above: paste any ciphertext produced by the plaintext-autokey model (or use the default), and watch the priming-key dials tick through every candidate from length 1 up to length 4 while a live leaderboard tracks the best-scoring decryptions found so far. Watch the leaderboard scores especially at the length transitions. A wrong length never gets close to plausible English, and the jump when the search finally reaches the correct length is unmistakable.

Autokey vs. Vigenère/Beaufort: What Actually Changes

Vigenère / Beaufort Autokey (this breaker)
Key structure Short keyword, repeats indefinitely over the whole message Short priming key, then the key is the plaintext, shifted
What’s actually searched The repeating key, any length up to ~20 Only the priming key, bounded to ≤4 letters here
Attack technique Kasiski + Index of Coincidence find the length, then per-column chi-squared Exhaustive brute force over short priming keys, scored by digram statistics
Why length-finding works or fails A repeating key creates periodic structure Kasiski/IC can detect No periodicity at all: the key never repeats, so Kasiski/IC give no usable signal
Effect of one wrong key letter Only its own column is wrong; the rest of the message stays plausible Cascades through the self-referencing chain and corrupts nearly everything after it
Guaranteed global optimum? Yes, per column Yes, within the bounded ≤4-letter search

Autokey is the more sophisticated design in exactly the way its own guide describes: it closes off Kasiski and the Index of Coincidence completely. But closing off one attack isn’t the same as being unbreakable: the priming key it still depends on is short enough that a completely different, much cruder technique (just try everything) works fine in its place.

Limitations of This Attack

This breaker deliberately assumes a short priming key: at most 4 letters, giving 475,254 total candidates. That bound is what keeps the search tractable in a browser tab; a 5-letter primer alone adds nearly 12 million more candidates, and 6 letters adds over 300 million. Past roughly 5-6 letters, exhaustive search stops being practical, and breaking Autokey requires the smarter, more targeted techniques the base Autokey guide alludes to. These include checking that both the recovered plaintext and the plaintext now sitting further ahead in the key stream simultaneously look like real language, or a known-plaintext (“crib-dragging”) attack that guesses a probable phrase and unwinds the key stream from there.

This breaker also only targets plaintext autokey: the variant where the key stream is extended with the message’s own plaintext. The ciphertext-autokey variant extends the key with the ciphertext instead, which is fully known upfront. Decrypting it doesn’t require guessing anything beyond the priming key at all, since every key letter past the primer is just a ciphertext letter the attacker already has. The same bounded brute-force idea applies there too, just without any cascading dependency on getting earlier letters right first.

FAQ

Why can’t Kasiski examination or the Index of Coincidence break Autokey the way they break Vigenère?

Both techniques depend on the key repeating with a fixed period, so that ciphertext positions sharing the same residue i mod L all share the same shift. Autokey’s key never repeats. Past the priming key, it’s the plaintext itself, so there’s no periodic structure for either technique to detect.

Why does one wrong priming-key letter wreck the entire rest of the decryption, not just nearby text?

Because every key letter past the primer is a previously decrypted plaintext letter. Get one letter wrong, and the wrong value gets reused as key material several positions later, producing another wrong letter, which gets reused again, and so on. The error compounds through the self-referencing chain instead of staying isolated to one position.

What’s the largest priming key length this attack can realistically brute-force?

This visualizer bounds it at 4 letters (475,254 total candidates), which stays comfortably fast in a browser. Each additional letter multiplies the remaining search by 26. Five letters is still plausible with more patience, but six or more starts requiring genuinely different techniques rather than just more compute.

Does this attack work on ciphertext-autokey too?

The same bounded brute-force idea applies, and it’s actually simpler: since the ciphertext-autokey key stream is built from the ciphertext (which the attacker already has in full), decryption doesn’t cascade the same way. Every key letter past the primer is already known, not dependent on a guess being right so far.

How long does the ciphertext need to be for this to work reliably?

Similar to this site’s other digram-based breakers: longer messages give the scorer more signal to separate the true primer from every wrong one. The visualizer’s default example runs to 639 letters, long enough that the correct priming key’s score comes out hundreds of points ahead of its closest competitor.

References

  1. Wikipedia. “Autokey cipher.” Available at: https://en.wikipedia.org/wiki/Autokey_cipher

  2. Practical Cryptography. “Autokey Cipher.” Available at: http://practicalcryptography.com/ciphers/autokey-cipher/

  3. Singh, Simon. “The Code Book.” Doubleday, 1999.