🔒Security & Privacy

Base64 Is Not Encryption: How Encoding Actually Works

Secrets 'hidden' in base64 are readable by anyone with a terminal. This post shows how 3 bytes really become 4 characters, where the 33% size inflation comes from, and what the trailing = signs mean. You'll also see why btoa() explodes on emoji, and when to reach for base64url, base32, or hex instead.

Published July 9, 2026
18 min read
By Toolsana Team

Somewhere right now, a developer is "hiding" an API key by base64-encoding it into a config file. Another is putting a user's email into a JWT payload, reassured by how scrambled eyJzdWIiOiIxMjM0... looks. A third is storing credentials in a Kubernetes Secret, comforted by the fact that kubectl get secret shows gibberish instead of the password.

All three are protected by nothing. Every one of those values decodes in a single command, with no key, no password, and no effort:

echo -n 'YWRtaW46aHVudGVyMg==' | base64 -d
# admin:hunter2

Base64 looks encrypted because it looks unreadable, and that resemblance causes real incidents — leaked credentials in "obfuscated" configs, sensitive claims shipped in tokens, secrets committed to git in plain sight. This post fixes the misconception properly: what encoding actually is, how base64 turns 3 bytes into 4 characters, why it inflates data by a third, which variant to use where, and the short list of gotchas (Unicode, padding, line wrapping, the +-becomes-space bug) that account for nearly every base64 error message you'll ever see.

Encoding, encryption, hashing: the three-way confusion

These three words get used interchangeably, and they are three entirely different operations with three different jobs:

What it doesReversible?Needs a key?Example
EncodingChanges how data is representedYes, by anyoneNobase64, hex, URL encoding, UTF-8
EncryptionMakes data unreadable without a keyYes, with the keyYesAES-GCM, ChaCha20, age, GnuPG
HashingProduces a fixed-size fingerprintNo, by designNo (salt for passwords)SHA-256, Argon2, bcrypt

Encoding is a public translation scheme, like writing a number in Roman numerals. XIV looks different from 14, but nobody would call it hidden — the rules for converting back are universal knowledge. Base64 is exactly this: a published, keyless mapping (RFC 4648) that anyone, and every programming language, can reverse instantly.

Encryption is the only one of the three that provides confidentiality. Its security lives entirely in the key: the algorithm is public, the ciphertext can be public, and the data stays unreadable anyway. If you need to hide data, this is the tool, full stop.

Hashing is a one-way fingerprint — you can check that data matches a hash, but you can't get the data back. It's the right tool for password storage (specifically the slow, salted kind: Argon2, bcrypt) and integrity checks. We dissected that whole domain in the password hashing guide.

The confusion matters because using the wrong one fails silently. Base64-"protected" secrets don't produce an error — they just sit there readable until someone reads them.

What base64 actually is

Base64 is a way to write any sequence of bytes using only 64 safe, printable characters: the letters A–Z and a–z, the digits 0–9, plus + and / (with = reserved for padding). That's the whole trick — it makes arbitrary binary data survive systems that only handle plain text.

Here's what it looks like in both directions:

echo -n 'Hi!' | base64
# SGkh

echo -n 'SGkh' | base64 -d
# Hi!

Try it yourself in the base64 encoder/decoder — it runs entirely in your browser via btoa()/atob(), so nothing you type leaves the page.

Why does Hi! become SGkh? Walk through the actual bits once and base64 stops being magic forever:

Input bytes:    H        i        !
As binary:      01001000 01101001 00100001   (3 bytes = 24 bits)

Regroup into 6-bit chunks:
                010010   000110   100100   100001
As numbers:     18       6        36       33

Look up in the base64 alphabet (A=0 … Z=25, a=26 … z=51, 0=52 … 9=61, +=62, /=63):
                S        G        k        h

That's the entire algorithm: take the input 3 bytes (24 bits) at a time, re-slice those 24 bits into four 6-bit numbers, and map each number to a character. Decoding runs the same table backwards. There is no key anywhere in this process — which is precisely why it provides no secrecy.

Why 6-bit chunks? Because 64 = 2⁶: an alphabet of 64 characters can represent exactly 6 bits per character. And the alphabet is 64 safe characters — no spaces, no quotes, no control characters, nothing that email systems, JSON strings, XML, or HTTP headers will mangle.

Where the 33% inflation and the = signs come from

Each base64 character carries 6 bits of information but occupies a full 8-bit byte on disk or on the wire. The overhead ratio is 8/6 = 4/3: every 3 bytes of input become 4 characters of output, a 33.3% size increase. A 3 MB image becomes a 4 MB data URI. This isn't a flaw to be optimized away; it's the arithmetic cost of representing 256 possible byte values with only 64 possible characters.

The = padding falls out of the same 3-to-4 grouping. When the input length isn't a multiple of 3, the last group is incomplete, and = fills the gap:

echo -n 'M'   | base64    # TQ==   (1 byte  → 2 chars + 2 padding)
echo -n 'Ma'  | base64    # TWE=   (2 bytes → 3 chars + 1 padding)
echo -n 'Man' | base64    # TWFu   (3 bytes → 4 chars, no padding)

The padding characters carry zero data — they exist so a decoder can confirm the length is valid and so concatenated base64 streams stay unambiguous. Two practical consequences: a base64 string's length is always a multiple of 4 (when padded), and strict decoders reject strings where it isn't. Python's base64.b64decode('SGVsbG8') raises binascii.Error: Incorrect padding for exactly this reason — the fix is appending = until the length divides by 4.

Encoding and decoding in practice

The literal commands, with the trap in each environment called out.

Terminal (GNU/Linux):

# Encode — the -n matters (see below); -w0 disables GNU line wrapping
echo -n 'hello' | base64 -w0
# aGVsbG8=

# Decode
echo -n 'aGVsbG8=' | base64 -d
# hello

# Files
base64 -w0 photo.png > photo.b64
base64 -d photo.b64 > photo-restored.png

One portability note: -w0 is a GNU coreutils flag. macOS ships the BSD base64, which doesn't wrap output at all — so there's nothing to switch off, and passing -w0 there just errors. Older macOS versions also decode with -D instead of -d.

The trap: echo without -n appends a newline, and you encode that extra byte:

echo 'hello' | base64     # aGVsbG8K   ← trailing K encodes the \n
echo -n 'hello' | base64  # aGVsbG8=   ← the actual string

If a base64 value works locally but fails as an API credential, this newline is suspect number one — the server is comparing against a secret that ends in \n.

JavaScript:

btoa('hello')      // "aGVsbG8="
atob('aGVsbG8=')   // "hello"

btoa('héllo')      // ❌ InvalidCharacterError: characters outside of the Latin1 range

The trap: btoa() encodes bytes, but JavaScript strings are UTF-16, and btoa() refuses any character above code point 255 rather than silently guessing an encoding. The fix is to make the bytes explicit — almost always as UTF-8:

// Encode any Unicode string as UTF-8 → base64
const b64 = btoa(String.fromCharCode(...new TextEncoder().encode('héllo 🚀')));

// Decode base64 → UTF-8 → string
const str = new TextDecoder().decode(
  Uint8Array.from(atob(b64), c => c.charCodeAt(0))
);

The newer Uint8Array.prototype.toBase64() and Uint8Array.fromBase64() do this properly (and support the URL-safe alphabet via an option) — cleaner in every way, but check browser support against your matrix before dropping the TextEncoder dance.

Python:

import base64

base64.b64encode(b'hello')        # b'aGVsbG8='  — note: takes bytes, not str
base64.b64decode(b'aGVsbG8=')     # b'hello'
base64.b64encode('héllo'.encode('utf-8'))  # encode str → bytes explicitly first

Python makes the bytes-vs-text boundary explicit: b64encode takes bytes and a str must be .encode()d first — the same UTF-8 decision JavaScript makes you handle manually.

Why base64 exists at all

Base64 was born from a hard constraint: early internet email could only carry 7-bit ASCII text. Send a binary file through a 1980s mail relay and bytes got mangled, stripped, or truncated. MIME (RFC 2045, 1996) standardized base64 as the way to wrap binary attachments in mail-safe text — which is why your email attachments are ~33% larger in transit than on disk, and why MIME base64 is wrapped at 76 characters per line.

The same "binary data must cross a text-only channel" problem recurs everywhere, and base64 is the standing answer:

  • JSON and XML have no binary type — APIs embed file contents, images, and cryptographic material as base64 strings.
  • HTTP Basic authentication sends Authorization: Basic <base64 of user:password> — encoding, not protection, which is why Basic auth is only acceptable over HTTPS.
  • Data URIs embed files directly in HTML and CSS (next section).
  • PEM certificates and keys — the -----BEGIN CERTIFICATE----- blocks — are base64-encoded DER (the certificate's raw binary format), wrapped at 64 columns (RFC 7468).
  • JWTs are three base64url segments joined by dots.
  • Kubernetes Secrets, cookies, web push keys, email attachments — the list doesn't end.

The pattern to internalize: base64 appears wherever bytes must pretend to be text. It's a transport adapter, and expecting a transport adapter to provide secrecy is the root of the misconception this post is named after.

The security misconception: "hidden" is not protected

Now the part that pages security teams. Because base64 output looks like ciphertext to a human, it gets used as if it were ciphertext. Three real patterns, each decodable by anyone in seconds:

Basic auth headers. That Authorization header in your captured traffic or server logs:

echo -n 'YWRtaW46aHVudGVyMg==' | base64 -d
# admin:hunter2

JWT payloads. A JWT's header and payload are unpadded base64url — signed, not encrypted. Take the standard example token, split on dots, decode the middle segment:

echo -n 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ==' | base64 -d
# {"sub":"1234567890","name":"John Doe","iat":1516239022}

Every claim, in plaintext, to anyone holding the token — including the user themselves, browser extensions, and anything that logs headers. The signature proves the token wasn't modified; it hides nothing. Putting emails, roles, or (it happens) passwords in JWT claims is publishing them. The full treatment — including JWE, the standard you actually want for encrypted claims — is in the JWT guide.

"Obfuscated" configuration. Kubernetes Secrets are base64-encoded by default — a transport formality, not a security feature, as the Kubernetes docs themselves stress. The same goes for connection strings tucked into .env files as base64, API keys in mobile app bundles, and credentials in git history. Anyone who can read the file can read the secret; strings piped through a base64 decoder is a standard first move in any security assessment precisely because this pattern is so common.

What to use instead depends on the actual requirement:

  • Data must stay confidential → real encryption: AES-256-GCM through a vetted library (libsodium, your language's audited crypto module), or age/GnuPG for files at rest. The security lives in key management, not in how the ciphertext is spelled — and the ciphertext, incidentally, is usually then base64-encoded for transport. Encoding and encryption compose; they don't substitute.
  • Passwords must be verifiable but never recoverable → slow salted hashing: Argon2id or bcrypt, per the password hashing pillar.
  • Secrets must reach production → a secrets manager (Vault, cloud KMS/Secrets Manager, sealed secrets), not an encoded string in the repo.

The one-line rule: if decoding requires no key, it was never hidden.

Standard vs URL-safe base64 (and when to use base32 or hex)

RFC 4648 defines two base64 alphabets, and mixing them up is a leading cause of invalid character decode errors.

Standard base64 uses + and / for values 62 and 63. Both characters are landmines in URLs: in a query string, + is decoded as a space, and / collides with path parsing. Put standard base64 in a URL and it corrupts silently in transit — the classic symptom is a token that validates when pasted manually but fails when clicked from an email link. (Percent-encoding is the general fix for characters that are unsafe in URLs — the URL encoder/decoder shows exactly which characters need it and why.)

Base64url solves this at the alphabet level: + becomes -, / becomes _, and padding = is usually dropped (it too has meaning in query strings). Same data, URL-proof spelling:

echo -n '<<???>>' | base64
# PDw/Pz8+Pg==          ← standard: contains / and +

python3 -c "import base64; print(base64.urlsafe_b64encode(b'<<???>>').decode())"
# PDw_Pz8-Pg==          ← base64url: - and _ instead

JWTs, OAuth state values, and web push keys all use unpadded base64url. To decode one with a standard tool you have to undo both changes: swap the two characters back with tr, then add = signs until the length is a multiple of 4. The awk snippet below is only that padding step — it keeps appending = until the string divides evenly by four:

echo -n 'PDw_Pz8-Pg' | tr '_-' '/+' | awk '{ while (length($0) % 4) $0 = $0 "="; print }' | base64 -d

Beyond the two base64 flavors, RFC 4648 also defines the other members of the family — the choice is a size-vs-robustness tradeoff:

EncodingAlphabetBits/charSize overheadUse when
Base64A–Z a–z 0–9 + /6+33%Default for machine-to-machine text channels
Base64urlA–Z a–z 0–9 - _6+33%The data travels in URLs, filenames, form fields
Base32A–Z 2–75+60%Humans read/type it; case-insensitive systems
Base16 (hex)0–9 A–F4+100%Byte-level debugging, hashes, checksums

Base32 trades density for human-friendliness: one case, no ambiguous symbols, survives case-mangling filesystems and being read over the phone. It's why TOTP authenticator secrets (the code you type when the QR scan fails) and Tor .onion addresses are base32. Note the aggressive padding — AB encodes to IFBA==== — because base32 works in 5-byte/8-char groups. The base32 tool handles both directions.

Hex doubles the size but maps one byte to exactly two characters, which makes it the convention anywhere humans inspect bytes: hash digests, MAC addresses, memory dumps, binary protocol debugging. The same Hi! from earlier is 486921 in hex — H is byte 0x48, i is 0x69, ! is 0x21 — so you can read the bytes straight off the string, no regrouping and no padding, ever. The hex encoder/decoder converts both ways.

Data URIs: base64 in the browser

A data URI embeds a file's contents directly into HTML or CSS instead of referencing it by URL:

<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB..." alt="1x1 pixel">

The format is data:<mediatype>;base64,<data>. That iVBORw0KGgo prefix is what every base64-encoded PNG starts with — you'll learn to spot it on sight, the same way eyJ at the start of a string means the data begins with {" and is almost always a JWT.

The honest tradeoff, because inlining is chronically overused:

  • The +33% applies to every load. A 30 kB icon becomes 40 kB of markup, and unlike a separate image file it can't be cached independently of the page, lazy-loaded, or skipped when off-screen.
  • It bloats the critical path. Base64 blobs in HTML/CSS delay first paint; a separate HTTP/2 request for a small image is cheaper than it was in the HTTP/1.1 era that made inlining popular.
  • Where it earns its keep: tiny assets (sub-2 kB icons, SVG cursors), single-file deliverables (self-contained HTML reports, email templates where external images are blocked), and build pipelines that inline below a size threshold automatically.

To convert an image in either direction, the image-to-base64 tool generates the complete data URI (ready for src or CSS url()) and decodes data URIs back to image files. Conversions run through our processing pipeline and the result is returned to your browser; nothing is stored after processing.

The gotchas that actually bite

Every one of these produces a real error message that people search verbatim.

Line wrapping: valid base64 your decoder rejects

GNU base64 wraps output at 76 columns by default (matching MIME); PEM wraps at 64. Paste a wrapped blob into a strict decoder — or into a JSON string, or an environment variable — and the embedded newlines break it. Encode with -w0 for single-line output, and strip whitespace before strict decoding:

tr -d '\n\r' < wrapped.b64 | base64 -d > file.bin

macOS's base64 doesn't wrap, which is exactly how "works on my machine" enters the story.

The alphabet mismatch

base64: invalid input or JavaScript's InvalidCharacterError on data you know is valid usually means a - or _ in the input: it's base64url, and your decoder expects standard. Translate with tr '_-' '/+' first. The reverse failure is subtler — standard base64 pasted into a URL arrives with + turned into spaces and decodes to garbage without any error at all.

Padding strictness

Python: binascii.Error: Incorrect padding. Java: IllegalArgumentException. Both mean the length isn't a multiple of 4 — usually an unpadded base64url string (every JWT segment) fed to a strict decoder. Append = until the length divides by 4, or use the language's dedicated URL-safe decoder (base64.urlsafe_b64decode still wants padding; java.util.Base64.getUrlDecoder() doesn't).

Double encoding

Base64-encoding data that's already base64 — common at system boundaries where each layer "makes it safe" defensively. The tell: decoding once yields something that still looks like base64 (only [A-Za-z0-9+/=], length divisible by 4), and decoding twice yields the data. It works, wastes 78% overhead (1.33²), and confuses every downstream consumer. Decode-and-check before encoding at boundaries.

The newline in the secret

Covered above, worth repeating as a checklist item because it burns CI pipelines constantly: echo without -n bakes a \n into the encoded credential. printf '%s' "$SECRET" | base64 -w0 sidesteps both this and the wrapping trap at once.

Recognizing and debugging base64 in the wild

A field method for "what is this string?":

1. Check the character set and length. Only A–Za–z0–9+/ (or -_) with optional trailing =, length a multiple of 4 (or unpadded)? Plausibly base64. Contains g–z? Then it's not hex. All uppercase-and-digits A–Z2–7? Try base32.

2. Learn the three famous prefixes.

eyJ          → {"     — a JSON object; almost certainly a JWT segment
iVBORw0KGgo  → PNG file signature
aHR0cHM6     → https:  — an encoded URL

3. Decode defensively. Pipe to a pager or xxd rather than straight to the terminal — decoded output may be binary and control characters can mangle your session:

echo -n '<suspect-string>' | base64 -d | xxd | head

4. For JWTs specifically, decode each segment separately:

TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
# {"sub":"1234567890","name":"John Doe","iat":1516239022}

(The 2>/dev/null mutes the padding complaint; GNU base64 emits the decodable prefix regardless.)

5. Round-trip when output looks wrong. Encode the decoded result and compare with the input. A mismatch means whitespace, alphabet, or truncation issues — not corrupt data.

For interactive work, the base64 tool does instant bidirectional conversion in your browser, and pairing it with the hex view is the fastest way to see exactly which bytes you're really working with.

The gotcha checklist

  1. Base64 is not encryption — no key, no secrecy, one command to reverse. Never "protect" anything with it.
  2. JWT payloads are readable by anyone holding the token — signed ≠ encrypted; sensitive claims need JWE.
  3. echo without -n encodes a trailing newline; use printf '%s' or echo -n.
  4. GNU base64 wraps at 76 columns by default — encode with -w0, strip \n\r before strict decoding.
  5. + becomes a space in URLs — use base64url (-_) for anything URL-borne.
  6. base64url is usually unpadded — strict decoders throw "Incorrect padding"; pad to a multiple of 4.
  7. btoa() throws on non-Latin-1 — go through TextEncoder/TextDecoder, or Uint8Array.toBase64() where supported.
  8. Expect +33% size (base32: +60%, hex: +100%) — budget for it in data URIs, payloads, and storage.
  9. Data URIs can't be cached separately — inline only tiny assets or single-file deliverables.
  10. eyJ means {" — treat any string with that prefix as readable JSON, because it is.

The short version

Base64 is a transport adapter: it re-spells bytes using 64 characters that survive text-only channels, at the cost of a 33% size increase, and anyone can reverse it instantly because there is no key — so it hides exactly nothing, whether it's wrapping a Basic auth header, a JWT payload, or an "obfuscated" API key. Use it for what it's for (binary data in JSON, data URIs, MIME, PEM), pick base64url when the data touches a URL, remember the mechanical traps — echo's newline, 76-column wrapping, btoa()'s Latin-1 limit, missing padding — and reach for real encryption or proper password hashing the moment confidentiality is the actual requirement. When you need to see what a string really is, the base64 encoder/decoder gives you the answer in your browser before the next incident writes itself.

Frequently Asked Questions

Is base64 encryption?

No. Base64 is an encoding — a public, keyless, perfectly reversible way of writing bytes using 64 safe characters. Anyone can decode it instantly with 'base64 -d' in a terminal, atob() in a browser console, or any online decoder; there is no key, no secret, and no computational barrier. Encryption, by contrast, requires a secret key to reverse, and without that key the ciphertext is computationally useless to an attacker. Base64 changes how data looks, not who can read it. If you need confidentiality, use real encryption (AES-GCM via a vetted library, or tools like age and GnuPG). If you need to store passwords, use a password hash like Argon2 — neither job is ever done by base64.

How do I encode and decode base64 on the command line?

Encode with 'echo -n 'hello' | base64' (output: aGVsbG8=) and decode with 'echo -n 'aGVsbG8=' | base64 -d'. The -n flag matters: without it, echo appends a newline and you encode a different byte sequence (aGVsbG8K instead of aGVsbG8=). For files, use 'base64 file.bin > file.txt' and 'base64 -d file.txt > file.bin'. Note that GNU base64 wraps output at 76 characters by default — pass -w0 to get a single line, which most APIs and config files expect. On macOS, the flag for decoding is -D on older systems and the output is unwrapped by default. In JavaScript use btoa()/atob() (ASCII only), in Python base64.b64encode() and b64decode() on bytes.

Why does base64 make data about 33% bigger?

Because it spends 8 bits to represent every 6 bits of input. Base64 has an alphabet of 64 characters, and 64 = 2^6, so each output character can carry exactly 6 bits of information — but that character is itself stored as a full 8-bit byte. The ratio is 8/6 = 4/3, so every 3 input bytes become 4 output characters: a 33.3% inflation, plus up to two padding characters and, in MIME contexts, line-break overhead. This is why inlining images as base64 data URIs enlarges them by a third and why email attachments are bigger on the wire than on disk. Hex encoding is worse (100% inflation, 2 characters per byte) and base32 sits between (60%). The inflation is the price of surviving text-only channels.

What do the = signs at the end of base64 mean?

They are padding, and they carry no data. Base64 processes input in 3-byte groups, emitting 4 characters per group. When the input length isn't a multiple of 3, the final group is short: one leftover byte produces 2 characters plus '==', two leftover bytes produce 3 characters plus '='. So 'M' encodes to TQ==, 'Ma' to TWE=, and 'Man' to TWFu with no padding at all. The padding exists so decoders can validate length and so concatenated streams stay parseable; RFC 4648 requires it in the standard alphabet but many base64url users (JWTs, for example) omit it because '=' needs percent-encoding in URLs. Strict decoders like Python's b64decode raise 'Incorrect padding' when it's missing — append '=' until the string length is a multiple of 4.

What is the difference between base64 and base64url?

Two characters and the padding convention. Standard base64 (RFC 4648) uses '+' and '/' as its 62nd and 63rd characters; base64url replaces them with '-' and '_' so encoded data can live inside URLs, filenames, and form fields without corruption. The problem it solves is real: in a query string, '+' is decoded as a space and '/' can collide with path parsing, so standard base64 silently corrupts in transit. base64url also commonly omits '=' padding, since '=' has meaning in query strings too. JWTs, many OAuth flows, and web push keys all use unpadded base64url. To decode base64url with a standard tool, translate first: tr '_-' '/+' then re-add padding. Mixing the two alphabets is a top cause of 'invalid character' decode errors.

Why does btoa() fail with 'characters outside of the Latin1 range'?

Because btoa() encodes bytes, but JavaScript strings are sequences of UTF-16 code units — and btoa() only accepts code units up to 255 (Latin-1). Feed it 'é', '日本語', or any emoji and it throws InvalidCharacterError rather than guess an encoding. The fix is to choose the byte encoding explicitly, almost always UTF-8: run the string through TextEncoder first — btoa(String.fromCharCode(...new TextEncoder().encode(str))) — and decode with TextDecoder on the way back. The newer Uint8Array.prototype.toBase64() / Uint8Array.fromBase64() methods make this cleaner and handle the base64url alphabet natively; check your support matrix before relying on them. The same trap exists in reverse: atob() returns a 'binary string' whose char codes are bytes, not decoded text.

When should I use base64 vs base32 vs hex?

Pick by channel constraints, not habit. Base64 is the default for machine-to-machine text channels — JSON payloads, data URIs, HTTP headers — because it's the densest of the three (33% overhead). Base32 (RFC 4648, alphabet A–Z and 2–7) costs more space (60%) but is case-insensitive and avoids ambiguous characters, which is why TOTP authenticator secrets and Tor .onion addresses use it: humans can read it aloud and case-mangling filesystems can't break it. Hex (base16) doubles the size but is trivially eyeballable byte-by-byte, which makes it the convention for hashes, checksums, MAC addresses, and anything you debug visually. All three are encodings, not protection — the choice affects robustness and size only.

Is it safe to put secrets in a JWT payload since it's base64?

No — a JWT's header and payload are unpadded base64url, readable by anyone who holds the token. Paste any JWT into a decoder, or split it on dots and run the middle segment through base64 -d, and the claims appear in plaintext: user IDs, emails, roles, whatever was put there. The signature at the end provides integrity (proof the token wasn't modified), not confidentiality (secrecy). Browser-held tokens are also visible in DevTools, extensions, and logs. Never put passwords, API keys, or sensitive personal data in JWT claims; if you genuinely need encrypted claims, that's JWE, a separate encrypted-token standard. For the full anatomy of JWTs and the mistakes that follow from this misconception, see our JWT guide at /blog/jwt-2026-structure-signing-security-mistakes/.

Share this post:

Related Posts