Three ways classical cipher tools go quietly wrong

Caesar, Vigenere and Atbash all look like table lookups, but case handling, non-letter characters and key alignment each fail silently when done wrong.

The site’s cipher tool covers Caesar, ROT13, Atbash and Vigenere. All four are textbook algorithms, under a hundred lines together, yet three details fail without an error — they just produce results that look plausible.

The Atbash mirror table, plus a row where solid cells participate in shifting and dashed cells pass through untouched

Figure: state advances only inside solid cells; spaces, digits and CJK pass through untouched.

Caesar: normalise the shift, leave non-letters alone

The naive version adds the shift to each character code:

text.split('').map((ch) => String.fromCharCode(ch.charCodeAt(0) + shift))

Two problems. A negative or large shift lands in punctuation territory, and spaces plus punctuation get shifted along with the letters.

Normalise first, then wrap only inside the alphabet:

export function caesar(text: string, shift: number): string {
  const step = ((shift % 26) + 26) % 26;
  return text.replace(/[a-z]/gi, (ch) => {
    const base = ch >= 'a' ? 97 : 65;
    return String.fromCharCode(((ch.charCodeAt(0) - base + step) % 26) + base);
  });
}

The regex only matches letters, so everything else passes through untouched, and case is preserved in both directions.

Vigenere: the key skips non-letters, not positions

Here is the broken version:

for (let i = 0; i < text.length; i += 1) {
  shift = key.charCodeAt(i % key.length) - 65;
  out += shiftChar(text[i], shift);
}

The index i stands for both the plaintext position and the key position. A space in the plaintext advances the key by one, and everything after it is misaligned.

The fix is a key cursor that advances only on letters:

let cursor = 0;
for (const ch of text) {
  if (!/[a-z]/i.test(ch)) {
    out += ch;
    continue;
  }
  const shift = key.charCodeAt(cursor % key.length) - 65;
  out += shiftChar(ch, shift);
  cursor += 1;
}

That is also how you do it by hand: the key follows the letters.

Atbash: it only exists for the Latin alphabet

Atbash mirrors A to Z and B to Y. It looks like the same kind of mapping as Caesar, but it assumes a fixed, symmetric alphabet.

Implement it as String.fromCharCode(219 - code) and Chinese text, digits and punctuation turn into garbage. Match [a-z] instead:

export function atbash(text: string): string {
  return text.replace(/[a-z]/gi, (ch) => {
    const base = ch >= 'a' ? 97 : 65;
    return String.fromCharCode(base + 25 - (ch.charCodeAt(0) - base));
  });
}

Chinese passes through unchanged. That is not a limitation of the implementation; the cipher’s domain never included it.

The shared lesson

All three are the same mistake wearing different clothes: these algorithms are defined on an alphabet, not on a string.

Two rules follow:

  • Bound the work with [a-z] and pass everything outside it through untouched
  • Advance every piece of state (shift, key cursor) only inside that bound

The tool page also keeps one line: classical ciphers are for teaching and puzzles, never for real encryption. That sentence matters more than the implementation.

← Back to all posts

Comments

…