Paste in text that's been encoded or encrypted. Ciphey works out how and hands you the plaintext.
No key, no cipher name, no hints. Base64, hex, Caesar/ROT13, Vigenère, Morse code and 19 more, several layers deep.
Install ·
Quick start ·
Features ·
Library ·
MCP ·
Docs ·
Discord
▶ Watch the one-minute tour (MP4, 61 s)
cargo install cipheyPrebuilt binaries for Linux (x86_64), macOS (Intel and Apple silicon) and Windows (x86_64) are on the releases page, each with a .sha256 checksum.
To build from source, you need a Rust toolchain:
git clone https://github.com/bee-san/Ciphey
cd Ciphey
cargo build --release # the binary is target/release/cipheyOr skip installing: join the Discord server, go to #bots and type $ciphey <your text> ($help lists the commands).
$ ciphey -t 'aGVsbG8gdGhlcmUgZ2VuZXJhbA=='
🕵️ I think the plaintext is Words.
Possible plaintext: 'hello there general' (y/N):
y
🥳 ciphey has decoded 64 times.
The plaintext is:
hello there general
the decoder used is Base64The first time you run it, a short setup asks for a colour theme, how you want results shown and whether to use a wordlist, and saves your answers to ~/.ciphey/config.toml.
ciphey -t 'NTA3NjYzNzU3MjZjMjA3NjY2MjA2OTcyNjU2YzIwNzM2ZTY2Njc=' # ROT13 → hex → Base64, nothing else needed
ciphey -f secret.txt # read the input from a file
ciphey -d -t '...' # no y/N prompt: take the first plaintext found (handy in scripts)
ciphey -c 15 -t '...' # keep searching for up to 15 seconds (the default is 5)
ciphey -r 'flag\{' -t '...' # only accept plaintext that matches a regex (a crib)
ciphey --wordlist words.txt -t '...' # also accept any exact match from a wordlistciphey --help lists every option.
▶ Watch the clip (16 s)
Three layers (ROT13, then hex, then Base64) come off in 0.16 s, measured by bash's time in a real recording. Here is the same comparison for more inputs, against Python Ciphey 5.14, the version Ciphey replaces:
| Input | Ciphey | Python Ciphey 5.14 |
|---|---|---|
| Base64 | 0.11 s | 0.92 s |
| Hex → Base64 | 0.14 s | 1.02 s |
| ROT13 → Hex → Base64 | 0.19 s | no answer within 60 s |
| URL → Base64 → Hex | 0.24 s | 1.15 s |
| Base64 ×4 | 0.41 s | 0.78 s |
| Hex → Base32 → Base64 → Hex | 0.54 s | 0.72 s, wrong answer |
| ROT13 → Hex → Base64 → Base32 | 1.15 s | no answer within 60 s |
Wall-clock median of 10 runs per input (Python Ciphey: 3) on a shared 16-CPU Linux machine, Ciphey at 47bd16d6 with the y/N prompt off and a fresh $HOME per run so its cache can't help. Every run was capped at 60 s. The script and raw numbers are in media/tui-video/bench on the media/readme-videos branch.
Where both get the right answer, Ciphey is 1.9 to 8.6 times faster. Where does the speed come from?
- It's Rust.
- An A* search tries the most promising chains of decoders first.
- Every decoder runs in parallel with Rayon, on up to 10 candidate texts at a time.
- Answers are cached in
~/.ciphey/database.sqlite, so the same input a second time comes back in milliseconds.
Ciphey doesn't need to be told what it's looking at. It searches chains of decoders (Base64 inside hex inside ROT13, four layers of Base64, and so on) and stops at the first candidate that looks like plaintext. By default it shows you that candidate and asks before accepting it (-d turns this off). The clip at the top of this page shows a four-layer decode.
There is also a timer: if Ciphey hasn't found anything after 5 seconds, it stops and says so (-c changes the limit).
It knows 24 decoders and crackers:
| Kind | Decoders |
|---|---|
| Base encodings | Base64 (standard and URL-safe), Base32, Base58 (Bitcoin, Flickr, Monero, Ripple), Base91, Base65536, Z85 |
| Other encodings | Hexadecimal, binary, URL (percent-encoding), Morse code, Braille, A1Z26, Citrix CTX1 |
| Ciphers | Caesar (including ROT13), ROT47, Atbash, Vigenère (it works out the key itself), rail fence, reversed text |
| Oddities | Brainfuck (it runs the program), Morse or binary written with other symbols |
More are on the way: #1030 tracks 109 decoders that aren't in yet.
▶ Watch the clip (21 s)
Every candidate plaintext also goes through LemmeKnow, the Rust port of pyWhat, which recognises more than 120 formats. So Ciphey doesn't just decode the string, it tells you what it is: a password in a mount or sshpass command, a TOTP secret, a GitHub token or Stripe key, an IP or MAC address, an email address or URL, a card number, a crypto wallet, an AWS ARN or a CTF flag.
$ ciphey -t '3139322e3136382e302e31'
🕵️ I think the plaintext is Internet Protocol (IP) Address Version 4.
Possible plaintext: '192.168.0.1' (y/N):▶ Watch the clip (14.5 s)
If you know part of the answer (the flag format, a word that has to be in there, how it starts), give it to Ciphey as a regex with -r. The other checkers switch off and only text that matches is accepted. This finds plaintext the English detection would pass over: Base64-encoded picoCTF{b4s3_64_1s_fun} comes back as gibberish by default, but with -r 'picoCTF\{' it's the first match.
--wordlist words.txt works the same way for exact matches: a candidate that is a line in the file counts as plaintext.
The first-run setup lets you pick a colour theme (Capptucin, Darcula, GirlyPop, the default, or your own RGB values) and choose between being asked about each plaintext or getting a list of candidates at the end. You can see it in the tour from 0:32. Everything is saved to ~/.ciphey/config.toml, which you can edit later.
The ciphey binary is a thin wrapper around the ciphey crate. The Discord bot uses it as well, and so can your code.
perform_cracking runs the whole search, as the ciphey binary does:
use ciphey::config::Config;
use ciphey::{perform_cracking, CipheyError};
fn main() {
let mut config = Config::default();
config.timeout = 5; // seconds
config.human_checker_on = false; // never prompt on stdin
config.api_mode = true; // don't print progress to stdout
// config.regex = Some(r"flag\{".to_string()); // only accept plaintext matching a crib
match perform_cracking("aGVsbG8gdGhlcmUgZ2VuZXJhbA==", config) {
Ok(Some(result)) => {
let path: Vec<&str> = result.path.iter().map(|step| step.decoder).collect();
println!("{} (via {})", result.text[0], path.join(" → "));
}
Ok(None) => println!("no plaintext found"),
Err(CipheyError::Timeout { secs }) => println!("gave up after {secs}s"),
Err(e) => eprintln!("error: {e}"),
}
}This prints hello there general (via Base64).
If you know what you're looking at, call that decoder. Each one is a function in ciphey::decoders: encodings come back decoded, ciphers are cracked, and the ones that take a key can decrypt with yours.
use ciphey::decoders;
let decoded = decoders::base64("aGVsbG8gd29ybGQ=");
assert_eq!(decoded.candidates[0].text, "hello world");
// No key: Ciphey tries every shift and marks the one its checks accept
let cracked = decoders::caesar("Uryyb jbeyq");
let plaintext = cracked.plaintext().expect("a shift reads as English");
assert_eq!(plaintext.text, "Hello world");
assert_eq!(plaintext.key.as_deref(), Some("13"));
// With the key
let decrypted = decoders::vigenere_with_key("Rijvs uyvjn", "KEY")?;
assert_eq!(decrypted.candidates[0].text, "Hello world");To choose the decoder at run time, decode_with takes its name or an alias, and list_decoders lists them all with their aliases, tags and the key they take:
use ciphey::{decode_with, list_decoders, DecodeOptions};
let cracked = decode_with("rot13", "Uryyb jbeyq", &DecodeOptions::default())?;
let decrypted = decode_with("affine", "IHHWVC SWFRCP", &DecodeOptions::with_key("a=5, b=8"))?;
for decoder in list_decoders() {
println!("{}: {}", decoder.name, decoder.key_format.unwrap_or("no key"));
}Nothing is filtered out: you get what the decoder hands on to the search. The candidate Ciphey's plaintext checks accept comes first and carries a detection; if they accept none, you get the decodings unmarked, for you to judge (all 25 Caesar shifts, say, though crackers with many keys hand on only their best few).
detect_plaintext runs the checks the search uses (a regex crib, a wordlist, LemmeKnow, common passwords and English) and says which one accepted the text and what it took it for:
use ciphey::detection::{detect_plaintext, CheckerKind, DetectOptions, Sensitivity};
let found = detect_plaintext("192.168.0.1", &DetectOptions::default()).unwrap();
assert_eq!(found.checker, CheckerKind::LemmeKnow);
assert_eq!(found.description, "Internet Protocol (IP) Address Version 4");
assert_eq!(found.confidence, Some(0.7)); // the format's rarity in pyWhat
// Pick the checkers and how strict the English checker is, or give a crib
let english_only = DetectOptions::new()
.checkers([CheckerKind::English])
.sensitivity(Sensitivity::Low);
let crib = DetectOptions::new().regex(r"^flag\{")?;cargo run --example decode tours all of this, and cargo run --example decode -- list lists the decoders.
perform_crackingreturnsResult<Option<DecoderResult>, CipheyError>onmaster(#915), and the single-decoder and detection functions are only onmasterso far. The last release on crates.io (0.12.0) still returnsOption<DecoderResult>, so until the next release use the git version:ciphey = { git = "https://github.com/bee-san/Ciphey" }.- The config is global to the process. The first call's
Configis used for every later call, and the single decoders follow it too (aregexcrib, a wordlist). They never prompt. - The API is documented on docs.rs.
▶ Watch the video (29.5 s). The chat replays a real Kiro CLI session with ciphey-mcp.
ciphey-mcp is a Model Context Protocol server, so AI assistants such as Claude Desktop and Kiro can decode text with Ciphey. It's behind the mcp feature, so the normal ciphey build doesn't include it:
cargo install ciphey --features mcp --bin ciphey-mcp
# or, from a clone of this repository:
cargo install --path . --features mcp --bin ciphey-mcpThe mcp feature isn't in a crates.io release yet (0.12.0 is the latest), so until the next one, install from git: cargo install --git https://github.com/bee-san/Ciphey ciphey --features mcp --bin ciphey-mcp.
It provides four tools:
decodedecodestextwhen you don't know how it was encoded, and returns theplaintextand thepathof decoders used, with keys such as the Caesar shift. Optional arguments:timeout_secs(1 to 30, default 10) andregex, a crib the plaintext must match, such asflag\{.decode_withruns one decoder you choose ontext: give it thedecoder's id, name or an alias (base64,rot13,vigenere,xor_single_byte, ...). Without akeyit decodes the text, or cracks the cipher by trying every key; with one it decrypts, for ciphers that take a key (13,LEMON,a=5, b=8,rails=3, offset=1). It returns every candidate decoding and whether each passes Ciphey's plaintext check.regexworks as fordecode.detect_plaintextchecks whethertextalready is plaintext, without decoding it, and says which checker accepted it, what it took it for and, for LemmeKnow's formats, how sure it is. Optional arguments:checkers(any oflemmeknow,passwordandenglish, by default all three),sensitivityof the English checker (low,mediumorhigh) and aregexcrib.list_decoderslists the encodings and ciphers Ciphey supports, with the ids and aliasesdecode_withtakes and the key format of each cipher that takes one.
A decode result looks like this. status is decoded, not_found or timed_out.
{
"status": "decoded",
"plaintext": "hello there general",
"path": [{ "decoder": "Base64", "key": null }],
"checker": "English Checker",
"timeout_secs": 10
}decode_with with {"decoder": "rot13", "text": "Uryyb jbeyq"} gives the result below. status is plaintext_found, no_plaintext (none of the candidates passed the check, so judge them yourself) or no_candidates (the text isn't in that decoder's format). A result holds at most 100 candidates and 65,536 characters of their text; total_candidates says how many there were, and a candidate cut short has truncated set.
{
"decoder": "caesar",
"status": "plaintext_found",
"candidates": [
{
"text": "Hello world",
"truncated": false,
"key": "13",
"is_plaintext": true,
"detection": { "checker": "english", "description": "Words", "confidence": null }
}
],
"total_candidates": 1
}detect_plaintext with {"text": "192.168.0.1"} gives:
{
"is_plaintext": true,
"detection": {
"checker": "lemmeknow",
"description": "Internet Protocol (IP) Address Version 4",
"confidence": 0.7
},
"checkers": ["lemmeknow", "password", "english"]
}Every call except list_decoders runs in its own short-lived process. Input is limited to 65,536 characters (keys too) and regexes to 1,000. A decode searches for at most 30 seconds and may use 1 GiB of memory; a decode_with call may run for 30 seconds and a detect_plaintext call for 10, with 256 MiB each. At most two decodes and four other calls run at once. The server doesn't read or write ~/.ciphey, so there's no config file and no cache.
Open Settings → Developer → Edit Config, add the server to claude_desktop_config.json, then restart Claude Desktop. Use the full path printed by which ciphey-mcp (where ciphey-mcp on Windows, for example C:\\Users\\you\\.cargo\\bin\\ciphey-mcp.exe), because Claude Desktop may not see your shell's PATH.
{
"mcpServers": {
"ciphey": {
"command": "/Users/you/.cargo/bin/ciphey-mcp"
}
}
}kiro-cli mcp add --name ciphey --command ciphey-mcpOr add the entry below to ~/.kiro/settings/mcp.json (all projects) or .kiro/settings/mcp.json (one project).
Most MCP clients take the same mcpServers entry: a stdio server started by ciphey-mcp with no arguments.
{
"mcpServers": {
"ciphey": {
"command": "ciphey-mcp",
"args": []
}
}
}- Plaintext detection isn't perfect. Very short phrases, text that isn't English, JSON and unusual flag formats can be missed or mistaken for something else. #1031 has the details and the planned fixes. If you know anything about the answer, a crib (
-r) or a wordlist helps a lot. - If a cached answer is wrong, delete
~/.ciphey/database.sqliteto clear the cache. - If you're stuck, ask in
#coded-messageson Discord.
- API docs on docs.rs
- The
docs/folder, including an overview, the architecture, how the A* search works and how plaintext is identified - Ciphey 2 documentation on Notion
- Introducing Ares, the blog post about the Rust rewrite (it was called Ares before it became Ciphey)
Bug reports, ideas and pull requests are welcome in issues. A new decoder is a good first contribution: pick one from #1030, and copy the shape of an existing one in src/decoders/. You can also sponsor the project.
- LemmeKnow by @swanandx identifies what Ciphey finds, and gibberish-or-not decides whether it's English.
- Rayon runs the decoders in parallel.
- Ciphey started as a Python project; Python Ciphey 5.x is still on PyPI. Thank you to everyone who worked on it.
- The videos are made with HyperFrames from real terminal recordings. The source, recordings and build script are in
media/tui-videoon themedia/readme-videosbranch.
We use AI for 2 things:
- The TUI is entirely vibe coded.
- I made AI spend hours researching every single CTF challenge out there. It created a list of 15,071 CTFs. It then went through every single CTF and looked for writeups. In those writeups it looked for anything related to encoding / decoding. It then created tests out of those. This enabled us to increase our testing coverage and make sure all CTF encoding / decoding challenges are solvable with this tool.
MIT. See LICENSE.




