Is Python Obfuscation Secure? I Tried to Break 5 Common Methods
It depends entirely on the method. The DIY tricks people reach for first — base64/zlib encoding, marshaling bytecode, stacking encode layers, or just minifying/renaming — are *not* secure: I reversed all five back to working source in well under a second each, and every one leaked its API key and license string in plaintext. Encoding is not encryption. Real protection starts when string literals are actually encrypted (so a text search and dis show nothing) and secrets live server-side or in a compiled native module — not in any of the copy-paste one-liners below.
"Just obfuscate it" is the most common answer to "how do I protect my Python?" — and most of the time it means one of a handful of copy-paste tricks off Stack Overflow or a random online tool. I wanted to know how much protection those actually buy, so I stopped theorizing and tested them.
I wrote one tiny script with a fake API key and a license check, obfuscated it five different common ways, and then timed how long it took me to get the original source (and the secrets) back out of each. Every method, the real obfuscated form, the exact reversal, and the clock. Spoiler: the slowest one took me about three and a half seconds, and that was only because it had bloated the file to 300 MB.
Everything below was run on Python 3.14 on Windows. You can reproduce all of it with the standard library — no special tools.
The victim script
Same script for every test. It has the two things people obfuscate to hide: a hard-coded credential and a license comparison.
API_KEY = "sk-live-4a91f7c2e8d0"
def check_license(key):
if key == "PRO-2026-ZQX":
return "unlocked"
return "locked"
print(check_license("PRO-2026-ZQX"))For each method I checked two things: did I recover byte-identical source, and are the secret strings (sk-live-4a91f7c2e8d0, PRO-2026-ZQX) sitting there in plaintext once I do?
Method 1 — base64 + exec (the one everyone posts)
The single most-recommended "obfuscation" is to base64-encode the source and exec it. It *looks* unreadable, which is the whole appeal:
import base64
exec(base64.b64decode('QVBJX0tFWSA9ICJzay1saXZlLTRhOTFmN2MyZThkMCIK...'))But base64 isn't a cipher — there's no key. It's a reversible alphabet. The exact same call that runs it, decodes it. The reversal *is* one line:
>>> import base64
>>> print(base64.b64decode('QVBJX0tFWSA9...').decode())
API_KEY = "sk-live-4a91f7c2e8d0"
def check_license(key):
if key == "PRO-2026-ZQX":
...
# byte-identical to the original — measured at 0.004 msRecovered in 0.004 ms, byte-for-byte, with all four secrets in plaintext. Anything the interpreter can decode to run, an attacker decodes to read. This buys you exactly nothing.
Method 2 — zlib + base64 ("but it's compressed")
The common upgrade is to zlib.compress first, then base64. Now the blob is shorter and looks even more random. Same fatal flaw: compression is not encryption, and the decompress key is, again, nothing.
import base64,zlib
exec(zlib.decompress(base64.b64decode('eJxzDPCM93aNVLBVUCrO1s3JLEvVNUm0...')))>>> zlib.decompress(base64.b64decode(blob)).decode()
# byte-identical original source, measured at 0.010 ms0.010 ms. You read the decompress call in the payload; you *are* the decompress call. Adding a second reversible transform just means pressing undo twice.
Method 3 — stacking 50 encode layers (the "layered" online tools)
Some online obfuscators lean into the illusion by encoding over and over — base64 of base64 of base64, dozens of times. The output is a genuinely huge wall of noise, which *feels* like security. It isn't; it's the same undo, in a loop.
I encoded the source 50 times. The payload ballooned to 313,469,352 bytes — nearly 300 MB — to hide seven lines. Then I peeled it with a five-line loop that just keeps decoding until it sees Python:
data = payload
while True:
try:
data = base64.b64decode(data, validate=True)
except Exception:
break
# stripped all 50 layers in 50 rounds, ~3.5 s
# (almost all of that is just shuffling 300 MB of bytes)
# result: byte-identical original source, secrets in plaintextLayering doesn't multiply the difficulty — it just adds mechanical, self-announcing steps. The only thing 50 layers reliably produces is a 300 MB file that's slower to *ship* than to crack.
Method 4 — marshal bytecode (exec(marshal.loads(...)))
This one *feels* more serious because the payload is real binary: a compiled code object, marshaled to bytes. No readable text in sight. But the code object is exactly what the interpreter runs, and Python ships the tools to read it in the standard library.
import marshal
exec(marshal.loads(b'\xe3\x00\x00\x00...')) # 357 opaque bytesTwo standard-library calls undo it. marshal.loads gives back the code object; dis disassembles it. And every string constant is readable straight off the object — no decompiler needed:
>>> co = marshal.loads(blob)
>>> [c for c in walk_consts(co) if isinstance(c, str)]
'sk-live-4a91f7c2e8d0'
'PRO-2026-ZQX'
'unlocked'
'locked'LOAD_CONST 2 ('PRO-2026-ZQX')
COMPARE_OP 88 (bool(==))
LOAD_CONST 1 ('unlocked')Total time from opaque binary to "here is the key and here is the exact comparison": 0.417 ms. If you want full .py source rather than bytecode, a decompiler like pycdc rebuilds it — that's the same engine behind our free .pyc Decompiler. But as with a raw .pyc, I never needed it; the constants and the disassembly already told me everything. (More on why in Python `marshal` explained.)
Method 5 — minify + rename (a real tool this time)
The encoding tricks all share one flaw — they're reversible with no key — so let's test the other popular approach: an actual minifier that renames identifiers. I ran the script through python-minifier with global and local renaming turned on. This is the real, unedited output:
A='PRO-2026-ZQX'
C='sk-live-4a91f7c2e8d0'
def B(key):
if key==A:return'unlocked'
return'locked'
print(B(A))Renaming did something real — check_license became B, API_KEY became C. But look at what it *didn't* touch: the API key is right there as 'sk-live-4a91f7c2e8d0'. The license code is right there as 'PRO-2026-ZQX'. The control flow is intact and trivially readable. No reversal step at all — I just *read* it.
Renaming-only obfuscation hides your *variable names*, which were never the secret. Your string literals — keys, tokens, URLs, license codes — stay in plaintext. This is the single most common gap in "obfuscated" Python I see.
The scoreboard
| Method | Reversal | Time | Secrets exposed? |
|---|---|---|---|
| base64 + exec | one b64decode call | 0.004 ms | Yes — all, plaintext |
| zlib + base64 | decompress + decode | 0.010 ms | Yes — all, plaintext |
| 50 nested layers | a 5-line decode loop | ~3.5 s* | Yes — all, plaintext |
| marshal bytecode | marshal.loads + dis | 0.417 ms | Yes — read off the code object |
| minify + rename | none — just read it | instant | Yes — strings untouched |
*The 3.5 seconds for 50 layers is almost entirely the cost of moving a needlessly-created 300 MB file around — not any actual difficulty. Every method returned working, byte-accurate source, and every method left the API key and license string in the clear.
So is Python obfuscation secure? The honest answer
Python is compiled and run on the user's machine, so the code — in *some* form — has to be present for the interpreter to execute it. That means no obfuscation is truly unbreakable; a determined attacker with enough time can always recover behavior. Anyone who tells you otherwise is selling something.
But that's not the same as "obfuscation is pointless." The realistic goal isn't *impossible*, it's *not worth it* — raising the cost of reversing your code far above the value of doing so. The five methods above fail because they raise the cost by roughly zero. What actually moves the needle:
- Encrypt the string literals, don't just encode the file. The reason every test above leaked its secrets is that the strings were never protected. Real obfuscation transforms literals so a text search and a
disdump turn up nothing usable. - Rename at the AST level, together with that encryption — so the disassembly is both anonymous *and* stripped of readable constants, not just short-named. Renaming alone (Method 5) is the trap.
- Keep true secrets off the client entirely. An API key or license authority that lives on your server can't be extracted from a download, no matter how it's wrapped. Validate licenses server-side.
- For the strongest bar, compile to a native module — a Cython
.pyd/.soor a Nuitka binary — so there's no bytecode tomarshal.loadsordisin the first place.
Our free Python Obfuscator does the first two — AST-level renaming *with* string encryption — so a decompile or disassembly of the result doesn't hand over your logic and keys the way every method on this page did. It's the difference between "looks scrambled" and "is actually scrambled." Pair it with server-side secrets and, for critical code, compiling to a binary, and you've moved from "cracked in 0.004 ms" to "not worth anyone's afternoon."
Rule of thumb: if the technique has no key, it's encoding, not protection. If your secret strings survive a plain text search of the output, you haven't obfuscated the thing that mattered.
Obfuscate so it survives this test — free
AST-level renaming plus real string encryption, so a decompile or dis dump gives up nothing. In your browser, no signup.
Open the Python ObfuscatorFree tools mentioned here
Frequently asked questions
Is Python obfuscation secure?
No obfuscation is unbreakable, because the code must be present on the machine that runs it. But the common DIY methods (base64/zlib encoding, marshal bytecode, stacking encode layers, minify-only renaming) are effectively no protection — each reverses to working source in well under a second and leaks string literals in plaintext. Real protection comes from encrypting string literals, keeping secrets server-side, and compiling to a native module — which raises the cost of reversing far above its value.
Can base64-obfuscated Python be reversed?
Instantly. base64 is a reversible encoding with no key, so the same base64.b64decode call that the exec() runs also recovers the original source — I measured it at 0.004 ms, byte-identical, with every secret in plaintext. The same goes for zlib+base64 and for stacking dozens of encode layers.
Does exec(marshal.loads(...)) protect my code?
No. The marshaled payload is a compiled code object — exactly what the interpreter runs. marshal.loads returns it, Python’s built-in dis disassembles it, and its string constants (keys, license codes) are readable directly off the object. In my test that took 0.417 ms with no decompiler. A decompiler like pycdc can rebuild full .py source if you want it.
Is minifying or renaming variables enough to obfuscate Python?
No. Renaming identifiers hides variable names, which were never the secret. Your string literals — API keys, tokens, license strings — remain in plaintext, and the control flow stays readable. In my test, python-minifier renamed check_license to B but left the API key and license code sitting in the output verbatim.
What actually protects Python source code?
Encrypt the string literals (not just encode the file) so a text search and disassembly reveal nothing; rename identifiers at the AST level alongside that encryption; keep real secrets off the client and validate licenses server-side; and for the strongest bar compile to a native .pyd/.so or a Nuitka binary so there is no bytecode to disassemble.
Can any Python obfuscation be truly unbreakable?
No. Because the interpreter needs the code to run it, a determined attacker with enough time can always recover behavior. The realistic goal is economic: make reversing cost far more effort than the code is worth. Strong obfuscation plus server-side secrets plus native compilation achieves that; encoding tricks do not.