While implementing JWT, I managed to get things working with the help of AI, but I couldn’t shake the feeling that I didn’t truly understand what was going on under the hood. It was hard to grasp, at a glance, how a token is actually created and how it is used for authentication. Eventually, that curiosity became impossible to ignore, so I decided to break the whole process down piece by piece. Since many backend developers struggle with the same questions, I decided to share what I learned.
In this article, we’ll cover the following topics:
- How is a JWT structured? (header.payload.signature)
- Who creates the header? Is it a random string?
- Is Base64 encoding a form of encryption?
- What is a signature, and why does modifying the payload get detected?
- What
.signWith(key, HS512)actually does
1. JWT Is Not an “Encrypted Token”
JWT is often referred to as a “token,” but in reality, it is simply composed of three string segments.
header.payload.signature
This is the most important point to understand.
The JWT payload is only Base64-encoded, which means anyone can decode it and read its contents.
For this reason, you should never put sensitive information (email addresses, phone numbers, or personal data) inside a JWT payload.
2. Who Creates the Header? Is It Random?
The JWT header is not something developers generate randomly. In most cases, it is automatically created by the JWT library.
For example, when you specify signWith like this, the library internally constructs the header.
Jwts.builder()
.setSubject(userId.toString())
.signWith(key, SignatureAlgorithm.HS512)
.compact()
The actual header typically looks like this:
{
"alg": "HS512",
"typ": "JWT"
}
alg: the algorithm used to sign the tokentyp: the token type (almost always JWT)
In other words, the header is not random at all—it is closer to configuration metadata.
3. Base64 Encoding Is Not “Hiding” or Encryption
The reason JWT headers and payloads are Base64-encoded is not to make them unreadable.
Base64 is used for transport and format safety.
JWTs are transmitted via HTTP headers, cookies, and URLs. Raw JSON (with characters like { }, ", and whitespace) can easily break these transports, so Base64 encoding converts the data into a safe string format.
4. Then What Does the “Signature” Do?
If anyone can decode the payload… what happens if an attacker modifies it?
Consider this payload:
{
"sub": "123"
}
Now imagine an attacker changes it to:
{
"sub": "1"
}
If the server blindly trusted this token, it would result in privilege escalation. That’s why JWT must be able to prove who created the token and whether it has been modified.
The signature is an anti-tampering mechanism that proves the token was created by the server and has not been altered.
5. Why Do We Base64-Decode jwt.secret?
You often see this line in Spring Boot JWT code:
private val key = Keys.hmacShaKeyFor(
Base64.getDecoder().decode(secretKey)
)
Here, key is not the signature itself. It is the secret signing key used to generate the signature.
jwt.secret: usually random bytes encoded as a Base64 string in configurationBase64 decode: restores the original byte arrayhmacShaKeyFor: builds a key object suitable for HMAC signing
In short, jwt.secret is the core security asset and must never be exposed.
6. What .signWith(key, HS512) Actually Does
Let’s look at an access token creation method:
fun createAccessToken(userId: Long): String {
val now = Date()
return Jwts.builder()
.setSubject(userId.toString())
.setIssuedAt(now)
.setExpiration(Date(now.time + ACCESS_TOKEN_VALIDITY))
.signWith(key, SignatureAlgorithm.HS512)
.compact()
}
Internally, this method performs the following steps automatically.
6-1. Header and Payload Creation
- Header: automatically generated (
alg=HS512,typ=JWT) - Payload: values we provided (
sub,iat,exp)
6-2. Base64URL Encoding
encodedHeader = Base64UrlEncode(header)
encodedPayload = Base64UrlEncode(payload)
6-3. Signature Generation (HMAC-SHA512)
The signature is not created by simply concatenating strings. It uses an HMAC cryptographic algorithm to generate a tamper-proof fingerprint.
dataToSign = encodedHeader + "." + encodedPayload
signatureBytes = HMAC_SHA512(key, dataToSign)
encodedSignature = Base64UrlEncode(signatureBytes)
6-4. Final Token Assembly
JWT = encodedHeader + "." + encodedPayload + "." + encodedSignature
In short,
.signWith(...)generates theencodedSignature, and.compact()produces the final JWT string.
7. Why Are Header and Payload Used Both in the Token and the Signature?
At first glance, this structure may look redundant.
However, it is essential for verification. Whenever a request arrives, the server extracts encodedHeader and encodedPayload from the token, recomputes the HMAC using the same key, and checks whether the resulting signature matches.
expectedSignature = HMAC_SHA512(key, encodedHeader + "." + encodedPayload)
tokenSignature = encodedSignature
expectedSignature == tokenSignature ? OK : TAMPERED
This is why changing even a single character in the payload results in a completely different signature, allowing the server to immediately detect tampering.
8. Summary
- JWT is based on signatures, not encryption
- The payload is Base64-encoded and readable by anyone → no sensitive data
- The header is metadata generated by the library, not a random string
jwt.secretis the secret key used to sign and verify tokens.signWith(key, HS512)generates the encodedSignature- The final JWT format is encodedHeader.encodedPayload.encodedSignature
JWT is not a mechanism to hide data, but a mechanism to prove that the data has not been altered.
In the next article, I plan to cover the differences between HS512 (HMAC) and RS256 (RSA), as well as strategies for managing and rotating secret keys.