Vigenère Cipher in C++: How It Works, and How to Break It

Three centuries as the indecipherable cipher, and 1.6 milliseconds to break. Working C++ for both directions, plus the attack that recovers the key.

Six tiles each displaced by a different amount and then restored, representing the varying letter shifts of a Vigenère cipher

The Vigenère cipher held out for about three hundred years. It was called le chiffre indéchiffrable — the indecipherable cipher — and people meant it.

A program at the bottom of this page recovered the key from 612 letters of ciphertext in 1.6 milliseconds, knowing nothing but the ciphertext itself. That gap, between three centuries and under two thousandths of a second, is the most useful thing this cipher has left to teach.

This guide covers how the shift works, a complete C++ implementation you can compile, the tabula recta, who actually invented it (not the man it is named after), and a working attack. Two kinds of evidence on this page, and they are worth telling apart. The standalone examples and the attack were compiled and run here on Ubuntu 24.04 with g++ 13.3 using -std=c++17 -Wall -Wextra — one machine, my word for it. The complete project is on GitHub, where a build runs on every commit against GCC, Clang and MSVC and fails unless the decrypted output matches the original byte for byte. Every output block below is captured verbatim.

What Is the Vigenère Cipher?

The Vigenère cipher is a polyalphabetic substitution cipher that encrypts text with a repeating keyword. Each letter of the keyword specifies a Caesar shift — A shifts by 0, B by 1, through Z by 25 — and that shift is applied to the corresponding letter of the message. When the keyword runs out it starts again. Because the same plaintext letter is shifted differently depending on its position, the letter-frequency pattern that breaks a Caesar cipher is concealed, which is why the cipher resisted attack until Friedrich Kasiski published a general method in 1863.

The important word in that definition is concealed, not removed. The frequencies are still there, spread across as many separate alphabets as the key has letters. Separate them again and the cipher falls apart.

Each letter gets its own Caesar shift The key repeats across the message. Its letter number is the shift applied to the letter above it. plaintext key shift ciphertext A T T A C K L E M O N L 11 4 12 14 13 11 L X F O P V the same T becomes X then F And this is why it is not encryption 612 letters of ciphertext, key unknown. The index of coincidence gives the key length, then letter frequencies give the key itself: recovered "crypto" in 1.6 ms Worked example and attack both compiled and run for this article with g++ 13.3, -std=c++17 -Wall -Wextra. Kasiski published the first general break in 1863; the cipher held for roughly 300 years.
The key letter is the shift. Encrypting ATTACK with LEMON, the two Ts become X and F because different key letters fall above them — which is what hides the letter frequencies a Caesar cipher leaks. It hides them from a human reader. A short program recovered the key from 612 letters of ciphertext in under two milliseconds.

How the Shift Works

Write the key repeatedly above the message, then shift each letter by the letter above it:

Position123456
PlaintextATTACK
KeyLEMONL
Shift11412141311
CipherLXFOPV

ATTACK with the key LEMON becomes LXFOPV. Look at the two Ts: the first becomes X, the second F, because different key letters landed above them. That is the whole idea, and it is also the whole weakness — there are only five distinct alphabets in play, one per key letter.

Decryption is the same operation with the shift subtracted instead of added.

The C++ Implementation

One function does both directions:

#include <cctype>
#include <iostream>
#include <string>

// Shift each letter by the corresponding key letter. Non-letters pass through
// unchanged and do NOT advance the key - that is the conventional behaviour.
// The key must be non-empty and alphabetic. An empty key would make
// k % key.size() a division by zero, which is a crash rather than a
// wrong answer - there is nothing sensible to return, so return the
// text unshifted and let the caller notice.
std::string vigenere(const std::string &text, const std::string &key, bool encrypt)
{
    if (key.empty()) {
        return text;
    }

    std::string out;
    out.reserve(text.size());
    std::size_t k = 0;

    for (unsigned char c : text) {
        if (!std::isalpha(c)) {
            out += static_cast<char>(c);
            continue;
        }
        const int base  = std::isupper(c) ? 'A' : 'a';
        const int shift = std::tolower(static_cast<unsigned char>(key[k % key.size()])) - 'a';
        const int off   = encrypt ? shift : 26 - shift;

        out += static_cast<char>(base + (c - base + off) % 26);
        ++k;
    }
    return out;
}

int main()
{
    const std::string key = "LEMON";
    const std::string plain = "Attack at dawn!";

    const std::string cipher = vigenere(plain, key, true);
    const std::string back   = vigenere(cipher, key, false);

    std::cout << "key       : " << key    << '\n';
    std::cout << "plaintext : " << plain  << '\n';
    std::cout << "ciphertext: " << cipher << '\n';
    std::cout << "decrypted : " << back   << '\n';
    std::cout << "round trip: " << (back == plain ? "ok" : "MISMATCH") << '\n';
    return 0;
}

Output:

key       : LEMON
plaintext : Attack at dawn!
ciphertext: Lxfopv ef rnhr!
decrypted : Attack at dawn!
round trip: ok

Four details are worth pointing at, because they are where implementations usually go wrong.

unsigned char in the loop and in the casts. std::isalpha, std::isupper and std::tolower take an int that must be representable as unsigned char or equal EOF. Pass a plain char that happens to be negative — which any byte above 127 will be on a platform with signed char — and the behaviour is undefined. This is the single most common bug in beginner <cctype> code.

The key index only advances on letters. Spaces and punctuation pass through and do not consume a key letter. That is a common convention, and both sides have to agree on it: advance the key on every character instead and your ciphertext will not decrypt with anyone else’s implementation.

Decryption adds 26 - shift rather than subtracting. Subtracting can produce a negative value, and % in C++ keeps the sign of the dividend, so (c - base - shift) % 26 can come out negative and index backwards out of the alphabet. Adding the complement avoids the problem entirely. The downloadable project at the end of this article uses the same approach for the same reason.

Case is preserved rather than forced, for the A–Z and a–z alphabet this example handles. base is chosen per character, so mixed-case input round-trips exactly. Bytes outside ASCII — the two that make up é in UTF-8, for instance — are not letters as far as std::isalpha is concerned, so they pass through untouched. This is a byte-oriented implementation for English text, not a Unicode-aware cipher. The output above shows it: Attack at dawn! returns with its capital A and its exclamation mark intact.

An empty key is a crash, not a wrong answer. k % key.size() divides by zero, which UndefinedBehaviorSanitizer reports as runtime error: division by zero and which terminates the process with SIGFPE on this machine. The guard above matches what the project in the repository does. The key is also assumed to be alphabetic — a digit in the key produces a shift outside 0–25 and a meaningless result.

The Tabula Recta

The pen-and-paper form of the same operation is a 26×26 grid — the tabula recta, which Johannes Trithemius published in 1508. Each row is the alphabet rotated one place further. Find the row for your key letter, the column for your plaintext letter, and read the ciphertext at the intersection:

Key rowABCSTU
AABCSTU
BBCDTUV
EEFGWXY
LLMNDEF
MMNOEFG

Numbering the letters A = 0 through Z = 25, that is all the cipher is:

encrypt:  C = (P + K) mod 26
decrypt:  P = (C - K) mod 26

The implementation below computes the second one as (C + (26 - K)) mod 26, for a reason specific to C++ that the next section explains.

Row E, column T, gives X — the second letter of our worked example. The grid is a lookup table for the arithmetic the code does with %, and nothing more. Bellaso’s contribution in 1553 was not the grid; it was the idea of using a keyword to choose which row you use for each letter.

Who Actually Invented It

Almost certainly not Blaise de Vigenère.

The cipher was described by Giovan Battista Bellaso in his 1553 book La cifra del Sig. Giovan Battista Bellaso, building on Trithemius’s tabula recta and on Leon Battista Alberti’s cipher disc of around 1467. In the nineteenth century it was misattributed to Vigenère and the name stuck. David Kahn, in The Codebreakers, wrote that history had ignored Bellaso’s contribution and instead named “a regressive and elementary cipher” after a man who had nothing to do with it.

Vigenère’s own cipher, published in his 1586 Traicté des Chiffres, was a genuinely stronger design: an autokey cipher, where the key begins with a single priming letter and then uses the plaintext itself to continue. Because the key never repeats, the attack in the next section does not apply to it — which is not the same as saying it cannot be broken, as the next paragraph shows. The stronger cipher lost its name to the weaker one.

There is a similar tangle over who broke it. The story usually told is that Charles Babbage got there first, around 1854, and never published — which is true, but he was working on the autokey variant. Kasiski is credited with the first published general solution to the repeating-key cipher, in his 1863 Die Geheimschriften und die Dechiffrir-Kunst. Both men are often described as having broken “the Vigenère cipher” when they were, strictly, breaking two different ciphers.

Breaking It

Here is the part that matters. The attack needs no key and no known plaintext — just enough ciphertext and some statistics.

Step 1: find the key length

The index of coincidence is the probability that two letters drawn at random from a text are the same. For English it is around 0.066; for random letters, 1/26 ≈ 0.038. Vigenère ciphertext sits near the random figure, because the letters come from several different alphabets mixed together.

But take every nth letter, where n is the key length, and you get letters that were all shifted by the same key letter — a Caesar cipher, which has English’s index of coincidence because a Caesar shift does not change letter frequencies, only relabels them. So try each candidate length and look for the one where the index jumps back up to English.

Step 2: solve each position as a Caesar cipher

Once the length is known, each of those groups is a single Caesar shift. Try all 26 shifts on each group and pick the one whose letter frequencies best match the expected distribution for the plaintext language — English here, via a chi-squared test in a few lines. That assumption matters: against source code, JSON, base64 or another language, you need that language’s frequency table instead. Each winning shift is one letter of the key.

Running it

Encrypting the opening of Pride and Prejudice with the key CRYPTO, then handing the ciphertext alone to the attack:

ciphertext letters: 612
overall IC        : 0.0414193  (English ~0.066, random ~0.038)

key length  average IC of cosets
    1       0.0414193
    2       0.0446159
    3       0.050227
    4       0.0440746
    5       0.0408544
    6       0.0641623   <-- English-like
    7       0.0414458
    8       0.0440915
    9       0.0512145
    10       0.0437864
    11       0.0395863
    12       0.0647059   <-- also English-like (a multiple)

chosen key length: 6
recovered key    : crypto

Read the table rather than just the answer. Lengths 6 and 12 both score like English, and 12 scores marginally higher. That is not noise — 12 is a multiple of the true period, so its groups are also uniformly shifted. An attack that simply takes the maximum recovers cryptocrypto, which is correct but redundant. Taking the smallest length that crosses the English threshold gives crypto.

Every other candidate sits between 0.039 and 0.051, comfortably below. With 612 letters the signal is not subtle.

Mean time over 20 runs, including process startup: 1.6 ms.

Why This Is Not Encryption

It is worth being blunt, because “encryption” appears in the title of every tutorial on this cipher, including the one this replaces.

The Vigenère cipher provides no meaningful confidentiality against anyone with a computer. The attack above is about a hundred lines of C++ and took less than two milliseconds. It needs no key material, no known plaintext, and no special hardware. Against a nineteenth-century cryptanalyst working by hand it was a genuine obstacle; against grep it is not.

Three specific properties make it useless for real work, and they are worth knowing because they are the properties modern ciphers were designed to fix:

  • The key repeats, which is the whole basis of the attack. Remove the repetition entirely — a key that is truly random, kept secret, at least as long as the message, and used exactly once — and you have the one-time pad, which is information-theoretically secure. All four conditions are load-bearing; a long key that is not random is not a one-time pad. That the fix is about the key rather than the shifting is why the repetition, not the arithmetic, is the flaw.
  • It preserves structure. Word lengths, punctuation and spacing survive encryption. Look at the ciphertext in the worked example: Lxfopv ef rnhr! still has the shape of Attack at dawn!.
  • It has no integrity protection. Anyone can flip letters in the ciphertext and the recipient decrypts the altered message with no indication anything happened.

If you need to actually protect something, use a vetted library implementing a modern authenticated cipher — AES-GCM or ChaCha20-Poly1305 — and do not implement it yourself. The value in the Vigenère cipher is educational: it is a clean illustration of why key reuse destroys a cipher, which is a lesson that still catches out production systems.

And note the difference from encoding, which is a distinct thing often confused with it. Our Base64 encoding and decoding article covers a scheme with no key at all, and no secrecy claim to make.

The Complete Project

The function above is the cipher. It is a teaching example, not a tool. Everything in the section above about why this cipher offers no real protection applies to the program just as much as to the thirty-line function — the point of the project is the class design, not the encryption. The full example — a small file-encryption program built around it — lives on GitHub in the MYCPLUS C++ examples repository, under the MIT licence.

Vigenère Cipher build

vigenere-cipher/
├── CMakeLists.txt
├── include/
│   ├── encryption.h
│   └── vigenere.h
├── src/
│   ├── encryption.cpp
│   ├── vigenere.cpp
│   └── encryption-driver.cpp
└── data/
    ├── Example.txt
    └── EncryptedText.txt

Clone it and build with CMake:

git clone https://github.com/mycplus/cpp-examples.git
cd cpp-examples/cryptography/vigenere-cipher
cmake -S . -B build
cmake --build build

Or straight from the compiler, which is what the CI does:

g++ -std=c++17 -Wall -Wextra -pedantic \
    src/encryption.cpp src/vigenere.cpp src/encryption-driver.cpp \
    -Iinclude -o vigenere_cipher

The program gives you a prompt rather than command-line arguments:

Enter a command:
encrypt [input file] [output file] [password]
decrypt [input file] [output file] [password]
quit
encrypt data/Example.txt data/EncryptedText.txt PASSWORD

Output — data/EncryptedText.txt:

Kefa rwul kiua

Decrypt it with the same password and Veni vidi vici comes back exactly as it went in, capital V included.

What the example demonstrates

The design is the part worth studying. EncryptedFileWriter derives from std::ofstream and adds one virtual method, encrypt, which the base class leaves as a no-op. VigenereEncrypt overrides it. The file-handling code knows nothing about ciphers and the cipher code knows nothing about files — swap in a different encrypt and everything else keeps working. That pattern, rather than the cipher, is what transfers to your own code.

One wrinkle is worth understanding before you extend it. The reader hands text over one whitespace-delimited word at a time, so VigenereDecrypt has to remember where it had reached in the key between calls. That is what key_pos is for, and it is why the driver copies the whitespace between words across separately: if a single character went astray, every letter after it would decrypt with the wrong key letter.

It is tested on every commit

The repository runs a GitHub Actions build on every push and pull request, across three toolchains:

JobCompilerPlatform
GCCg++ -std=c++17 -Wall -Wextra -pedanticUbuntu
Clangclang++ -std=c++17 -Wall -Wextra -pedanticUbuntu
MSVCcl /std:c++17 /W4 /EHscWindows

Each job does more than compile. It encrypts Example.txt, decrypts the result, and diffs the output against the original — if a single byte differs, the build fails. The badge above is live, so if it is green as you read this, that round trip passed on all three compilers against the current code.

Key Takeaways

  • Each key letter is a Caesar shift applied to the corresponding message letter; the key repeats when it runs out.
  • Pass unsigned char to <cctype> functions. std::isalpha on a negative char is undefined behaviour, and it is the most common bug in this kind of code.
  • Decrypt by adding 26 - shift, not by subtracting — % in C++ keeps the sign of the dividend, so subtraction can produce a negative index.
  • Do not advance the key on non-letters if you want your output to interoperate with other implementations.
  • The index of coincidence gives you the key length, and each position is then a plain Caesar cipher solved by frequency analysis. Prefer the smallest length that looks like English — multiples of the true period score just as well.
  • A full break took 1.6 ms on 612 letters of ciphertext with no prior knowledge.
  • The cipher is named after the wrong person. Bellaso described it in 1553; Vigenère’s actual cipher was a stronger autokey design that the repeating-key attack does not touch.
  • Never use it for anything real. No confidentiality, no integrity, and structure preserved in the ciphertext.

Frequently Asked Questions

Conclusion

The Vigenère cipher is a good first cipher to implement and a terrible one to rely on, and the distance between those two facts is the point. Thirty lines of C++ gets you a working implementation. A hundred more gets you a working break. Three centuries of reputation did not survive either.

What is worth carrying away is the specific reason it fails. Not that the shifting is weak — a Caesar shift is fine as a building block — but that the key repeats, and repetition lets an attacker split one hard problem into several easy ones. That failure mode did not retire with the nineteenth century; it is the same reason a nonce must never be reused with a stream cipher. Our other C++ programming guides cover the language ground, and if you are building up from the basics, the complete guide to C++ programming is the place to start.

What I Could Not Verify

The cross-compiler question is settled by the build above rather than by me: GCC, Clang and MSVC all compile the project and all pass the round-trip test on every commit. What follows is what that does not cover.

The attack is mine alone. It was written and timed on one machine — an Ubuntu 24.04 VM with g++ 13.3 — and is not part of the repository or the CI. The 1.6 ms figure is a mean over 20 runs including process startup, so treat it as an order of magnitude rather than a measurement. It was also tested against a single ciphertext: 612 letters of English prose with a six-letter key. Shorter texts and longer keys weaken the index-of-coincidence signal, and I did not map where it stops working.

The build covers C++17 on Ubuntu and Windows. It does not cover macOS or Apple Clang, other C++ standards, or sanitizer runs — I ran AddressSanitizer and UndefinedBehaviorSanitizer locally and both were clean, but that is my machine’s word rather than the build’s.

The history comes from secondary sources, including Kahn’s The Codebreakers as cited by them. I have not consulted Bellaso’s 1553 or Kasiski’s 1863 originals, and sources differ in emphasis, particularly on which cipher Babbage actually broke.

Scroll to Top