When a Broken JWT Still Validates
A misunderstand for Base64 & Lenient Decoding
When a Broken JWT Still Validates
A misunderstand for Base64 & Lenient Decoding

Image src: https://www.istockphoto.com/photo/iceberg-with-above-and-underwater-view-gm874800702-244248733
Intro
The JWT signature, the last part of the JSON Web Token, is designed to protect the token from tampering. So naturally, I assumed that changing even a single character in it should immediately invalidate it.
While exploring with an educational POC, for some reason I modified the last part of a JWT signature expecting the verification process to fail. Surprisingly, the token was still accepted as valid by the JWT library I was testing with.
At first, this looked like a serious issue. But after exploring, I discovered that the behavior was actually related to how Base64 decoding works internally and specifically the concept of lenient decoding which was new for me. In this article, we gonna walk through the full story step by step.
About Base64 Encoding
Simply, Base64 is an encoding mechanism that converts binary data into readable text characters.
It was never designed for security. Its main purpose is compatibility, which means allowing systems to safely transfer binary data through channels that are designed to handle text only.
The Core Idea
Computers store info as bytes, where each byte contains 8 bits and represents a value between **0 and `255`**.
Base64 takes those bytes and transforms them into characters using a predefined alphabet containing 64 different symbols:
ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
0123456789
+ /
JWTs use a slightly modified version called Base64URL. The only diff is the last two characters:
ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
0123456789
- _
The reason for this modification is practical as characters like
**+and `/** can create problems inside **URLs** and **HTTP contexts**, so they are replaced with-` and**_**.
How Base64 Works Internally?
Base64 operates on groups of bits, the process is simple:
- Take the original bytes.
- Convert them into binary.
- Split the binary stream into groups of 6 bits.
- Map each 6-bit group to a character from the Base64 alphabet.
Why 6 bits ?? Because 2⁶ = 64 which matches the 64-character Base64 alphabet. A 6-bit value can represent numbers from
**0to `63`**, which perfectly matches the size of the Base64 alphabet.
Example: encoding “Hi”
Step 1: convert characters to bytes using ASCII values
H = 72
i = 105
Binary representation:
H = 01001000
i = 01101001
Combined binary stream:
0100100001101001
Step 2: split into 6-bit groups
010010 000110 1001
You see, the final group is incomplete (contains only 4 bits). To complete the encoding process, extra bits are added, this called padding.
010010 000110 100100
Step 3: Convert each group to decimal
010010 = 18
000110 = 6
100100 = 36
Step 4: Map values to Base64 characters
Each decimal value acts as an index inside the Base64 alphabet:
18 = S
6 = G
36 = k
Result of encoding **Hi is `SGk=**and the=` at the end is padding.
Why this padding exists?
Base64 operates on fixed-sized groups. If the original data length does not align perfectly with the encoding boundaries, padding is added to make the final encoded output structurally valid, just that.
The important detail is that padding does not contain any original info. Its purpose is only to help the decoder to re-construct the original byte boundaries correctly.
Encoding != Encryption
This is one of the most common misunderstandings in software engineering, and honestly I used to confuse them too : )
Base64 is an encoding format, not an encryption mechanism.
Encoding simply transforms data into another representation for compatibility or transport. The process is completely reversible and does not require any secret key. Anyone literally anyone who receives a Base64-encoded value can decode it immediately.
Encryption is fundamentally different, its goal is confidentiality. It requires a secret key and is specifically designed to prevent unauthorized access to the original data.
This distinction is extremely important when working with JWTs:
- Base64 does not protect JWT contents.
- Sensitive information should never be stored inside a JWT unless it is encrypted before being encoded.
How does the JWT Decoding Validation Happen?
A JWT consists of three sections separated by dots:
HEADER.PAYLOAD.SIGNATURE
Each section is encoded using Base64URL, example:
**eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4ifQ.abc123signature**
The first part represents the header, the second contains the payload, and the third is the cryptographic signature used to verify that the token was not modified.
We can say that the process happens in two stages:
First JWT validation, which checks whether the token has a valid structure, correct formatting, and all required components and claims.
After that, JWT signature verification happens, where the cryptographic validation process described below is performed.
If the JWT fails the initial validation stage due to an invalid structure or malformed content or for any other reason, the signature verification process is never executed, and the request is immediately rejected with an unauthorized response.
JWT Signature Verification
When a server receives a JWT, it does not simply trust the token because it exists or because it can be decoded. The server must verify that the token was really generated by a trusted source and that its contents were not modified after creation.
The verification process usually happens in the following order:
- The server decodes the first two sections (header and payload) to read their JSON content. The header contains metadata like the signing algorithm (
**HS256, `RS256**, . . . etc) and the payload contains the **claims** or data stored inside the token likeusername`. - The server rebuilds the original signing input (the input used to sign this token) and the signature is not generated from the decoded JSON directly, no it is generated from the encoded values exactly as they originally appeared inside the token:
**base64Url(header) + "." + base64Url(payload)**This detail is very important because if there’s a tiny change in the encoded content, this will produce a completely different signature. - Using the same signing algorithm in the header and same cryptographic key, the server generates a new signature. In other words, the server tries to calculate what the signature should be for the received token content.
- Finally, the server compares the generated signature against the signature from the received JWT and according to the result of this comparison, the decision is taken to authorize or not.
The Important Detail
For some reason, you may understand (and I was) that JWT verification compares the text values of the two signatures but that is not what usually happens internally.
JWT libraries first decode the Base64URL signature into its original binary bytes, then compare those decoded bytes against the newly generated signature bytes.
In other words, the comparison is often based on the decoded binary data not on the exact textual Base64URL representation itself that you see as developer.
That small detail becomes extremely important, because in some situations, two slightly different Base64URL strings can still decode into the exact same binary data. And when that happens, the JWT may still validate successfully even though the visible signature text was modified.

Although the input differs (the first string has additional character) the output identical.
You can try the above sample using: ***online JWT converter***
For some reason that’s what happened with my case during testing and this is why this article is written and you’re reading it right now, continue . . .
JJWT: The Problem I Faced
As mentioned earlier, I tried appending an extra character to a JWT token expecting the validation process to fail immediately but it was still valid.
This behavior initially looked incorrect, but after digging deeper, the explanation became clearer.
The first important point is that everything discussed above is fundamentally related to Base64 encoding itself, not JWT specifically. JWT simply depends on Base64URL as part of its structure and signature representation. In other words, this behavior is inherited from the encoding mechanism being used.
However, another important detail that completes the full picture: some JWT libraries, like [**JJWT](https://github.com/jwtk/jjwt), implements a lenient Base64 decoding process. This means the library accepts slight textual differences** in the encoded value as long as those differences do not change the actual underlying decoded bytes.
For example (they mentioned this in the documentation):
- Changing unused trailing Base64 bits
- Appending ignored invalid characters at the end
- Adding characters that do not affect the decoded binary output
may still result in successful validation because the cryptographic verification process operates on the decoded byte array, not necessarily on the exact textual representation of the Base64 string.
You might want to reject any modified token text or any appended characters even if the decoded bytes remain identical. In such cases, this validation must be implemented at the application level rather than relying entirely on the JWT dependency itself.
In addition to testing **jjwt, I tried the same POC using the `[jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken)** dependency in a NodeJS service. This time, the behavior was different: as soon as I modified the token by appending one extra character, the verification process failed immediately with error message:invalid signature`

After investigating the library’s verification flow and reading parts of its source code, specifically [**verify.js](https://github.com/auth0/node-jsonwebtoken/blob/master/verify.js), it appeared that it handles the decoding more strictly compared to `jjwt`**.
While both performs same thing, **jjwt implementors preferred the lenient decoding model focused on the underlying binary integrity, while `jsonwebtoken` enforced the strict model to ensuring everything is identical.**
The Engineer Responsibility in the AI Era
This whole investigation started after using the AI to generate an educational POC for learning purpose. During testing, I faced this behavior that looked weird and that pushed me to investigate, experiment, and dig deeper until I understood all those stuff.
AI has completely changed the way developers build software, also affected the learning curve, we can’t ignore this. It can generate boilerplate code, accelerate implementation, explain concepts, and remove a huge amount of repetitive work in shorter time. For learning and productivity, especially in software engineering, it is an incredibly powerful tool. And this situation reminded me of something very important, I think I must mention:
As engineer, you cannot stop at “the code works”
In my case, the generated implementation looked perfectly fine during normal testing. If relied only on surface-level testing, I could have misunderstood this issue completely.
The real lesson was not only about JWTs but also about engineering responsibility in the AI era. AI can help you write code faster, but it does not replace the need to understand how the underlying libraries work, what assumptions those libraries make, which edge cases exist or may happen, and whether the chosen implementation is actually appropriate for your use case or not.
Modern libraries often contain behaviors that are intentional, optimized, or standards-compliant, but may look incorrect until you understand the internals behind them.
AI is excellent at reducing implementation time and handling repetitive technical details but engineering is not about producing working code but about validating assumptions, understanding trade-offs, identifying edge cases, and ensuring the system behaves correctly under unexpected conditions.
In my opinion, the role of the engineer is evolving, not disappearing. AI is changing how we work and shifting where our effort goes, but the engineer still remains at the center. The more AI handles routine tasks, the more valuable deep technical understanding becomes.
I hope this article has added to you any new term and thanks for your time.
메타데이터
- post_id
- ec517b7cbcee
- slug
- when-a-broken-jwt-still-validates-ec517b7cbcee
- url
- https://medium.com/@m.hassan.def/when-a-broken-jwt-still-validates-ec517b7cbcee
- canonical_url
- https://medium.com/@m.hassan.def/when-a-broken-jwt-still-validates-ec517b7cbcee
- author_url
- https://medium.com/@m.hassan.def
- status
- ok
- fetched_at
- 2026-07-10 15:20:15