Developer's Handbook: URL Encoding, Base64 & JWT

URL encoding, Base64 and JWT are three tools developers use daily and misuse almost as often. Each solves a different problem: transport, representation, or identity claims. None of them is encryption. This handbook covers the rules, the underlying mechanics, and practical debugging habits.

Why Encoding Exists

Transmitting data across networks depends on shared agreement about what is structure and what is content. URLs use ?, & and = to delimit parameters. A value containing those characters would be misread as syntax. Older protocols such as email and HTTP headers were built around 7-bit ASCII. Chinese text, images, and binary files therefore need a text-safe representation. Encoding is a convention that rewrites data so it survives a specific channel without being misinterpreted. It provides no secrecy whatsoever.

URL Encoding: The %XX Rules

Percent-encoding replaces unsafe characters with % followed by two hexadecimal digits. A space becomes %20. The value "a=1&b=2" must be written a%3D1%26b%3D2, or the & will be read as a parameter separator. Letters, digits and the four characters - _ . ~ are "unreserved" and pass through untouched.

In JavaScript, the two built-in functions are a classic source of bugs.

  • encodeURIComponent escapes everything except A–Z, a–z, 0–9 and - _ . ! ~ * ' ( ). It is the right choice for parameter values.
  • encodeURI additionally preserves structural characters like : / ? #. It is the right choice for a complete URL.

The classic mistake is running query values through encodeURI. The & and = then slip through and confuse the server. Also note: a plus sign means a space only in form submissions (application/x-www-form-urlencoded). In a path it is literally a plus. When in doubt, paste a sample into the site's URL Encoder/Decoder and compare.

Base64: How 3 Bytes Become 4 Characters

Base64 splits every 3 bytes (24 bits) into four 6-bit groups. Each group maps to one symbol of a 64-character alphabet: A–Z, a–z, 0–9, + and /. The bytes of "Man" become the groups 010011, 010110, 000101, 101110, which spell "TWFu". When the byte count is not a multiple of three, = pads the tail: two padding characters for one leftover byte, one for two. This is why Base64 inflates size by about a third.

Because + and / collide with URL syntax, a URL-safe variant swaps in - and _ and usually drops the padding. JWT uses exactly that variant.

Base64 Is Not Encryption

Base64 involves no key; anyone can reverse it in one step. Its only job is carrying arbitrary bytes through text-only channels. "Encrypting" a password by Base64-encoding it is a textbook vulnerability. Whenever the database leaks, attackers decode it for free. Use symmetric encryption such as AES for confidentiality. To verify that content was not tampered with, compute a digest such as SHA-256 with the site's Hash Generator and compare.

JWT: A Three-Part Credential

A JWT consists of three dot-separated parts: header.payload.signature. The first two are Base64URL-encoded JSON. The header names the signing algorithm, for example HS256. The payload carries claims such as iss (issuer), sub (subject, usually a user ID), and aud (audience). Other common claims are exp (expiry timestamp), iat (issued at) and jti (a unique token ID). The signature is computed over the first two parts, using the algorithm the header declares.

HS256 versus RS256 comes down to key structure. HS256 is symmetric: the same secret signs and verifies, which suits a single application. RS256 is asymmetric: a private key signs, a public key verifies. Verifiers can never issue tokens, so it fits microservices and third-party integrations.

Expiry, Revocation and Debugging

JWTs are stateless: once issued, a token cannot be recalled. The server cannot tell whether a token is void without keeping state. The standard pattern is a short exp, minutes rather than days, paired with refresh tokens. Emergency bans work by blacklisting the jti or bumping a per-user token version, at the cost of reintroducing state. Remember that the payload is merely encoded, not sealed. Anyone holding the token can read it, so secrets never belong there.

When signature verification fails, the dumb-but-effective fix works best. Split the token into three segments and decode each with the site's JWT Parser or Base64 Encoder/Decoder. Check that the header's alg matches the server's configuration. Check that exp has not passed. Check that the signature segment arrived intact, because gateways and careless clients love to truncate. Most "signature verification failed" errors are not a broken algorithm. The cause is a wrong secret, a truncated token, or a verifier that re-encodes the payload so the bytes differ from what was signed.

Common mistakes and how to fix them

Encoding bugs share one signature: everything looks right, but the bytes are not. The debugging habit is always the same. Dump the intermediate value and compare byte by byte. These mistakes recur in production constantly.

  • Encoding a value that was already encoded. The mistake: the %20 in a template gets encoded again into %2520. After one server-side decode, the stray %2520 survives. Chinese text turns into nested layers of %25 sequences. Double encoding is the most frequent "both sides are correct yet the result is wrong" bug, because the extra pass hides somewhere in the chain. The fix: agree that encoding happens exactly once, at the output boundary. Search your logs for %25. If it appears, something encoded twice, and you delete the redundant pass.
  • Feeding Chinese text straight into btoa. The mistake: btoa only accepts Latin-1 characters, so the first CJK character throws InvalidCharacterError. The legacy unescape(encodeURIComponent(s)) workaround still mishandles supplementary-plane characters such as emoji. The fix: convert the string to UTF-8 bytes with TextEncoder first, Base64-encode the bytes, and reverse the same order when decoding.
  • Passing a JWT in the URL query string. The mistake: the token lands verbatim in access logs and browser history. It can also reach third-party assets through the Referer header. Logs are kept for years, so you have written a long-lived credential into the most-leaked place in your stack. The fix: send it in the Authorization header. For genuinely one-time links such as email verification, use a short-exp token that is invalidated the moment it is used.
  • Decoding JWT segments with standard Base64. The mistake: JWTs use the Base64URL alphabet. The characters - and _ do not exist in the standard Base64 table, so many decoders throw or fail silently. Padding is usually omitted too, and naive 4-character grouping misaligns the bytes. The fix: replace - with + and _ with / before decoding, append the required = padding, or use a parser that understands Base64URL natively.
  • Trusting the alg field in the header when verifying. The mistake: an attacker can set alg to none and skip verification entirely. Another trick is RS256/HS256 confusion: using the public RSA key as an HS256 secret to forge signatures. Choosing the algorithm from the header lets the attacker pick the lock. The fix: hard-code the expected algorithm server-side. Reject tokens whose alg is none or anything outside the allowlist.
  • Re-serializing the payload with JSON.stringify before verifying. The mistake: the signature was computed over the exact encoded bytes. Re-serializing changes key order or whitespace, so the bytes no longer match and verification fails. The error then points at a "wrong secret" and sends your debugging down a dead end. The fix: verify against the two original segments exactly as received. Use the parsed object only for reading claims.

Tools mentioned in this guide