CodeGym /Courses /Spring Security /JWT: header, payload, and signature

JWT: header, payload, and signature

Spring Security
Level 22, Lesson 1
Available

1. The benefit of being able to “read” JWT

In a real project, JWT almost never fails “cleanly”. It fails in a way that makes you see 401, the client says “nothing works”, and in the logs you get at best the mysterious Invalid token. At times like these, it is useful to be able to answer quickly: “is this even JWT?”, “does it have three parts?”, “can header/payload be decoded?”, “why does the signature not match?”.

We have already looked at JWT as a portable auth artifact for a stateless request. Now it is important to strip away the aura of magic: you need to be able to break the token into parts and quickly understand what is actually inside it.

Reading JWT is not about cryptography or building a token platform. It is about engineering hygiene. A practical example: you received a token from the frontend, it “looks like JWT”, but you are not sure whether it got truncated on the way, contains extra spaces, arrived with the wrong encoding, and, in general, whether it is really what you expect.

Another reason: when you start building a stateless model in our Secure Content Platform API, you will get a new type of artifact in the system — a token. And, like any artifact (JSON, HTTP request, SQL query), a developer must be able to “read it with their eyes” and quickly understand what they see. That saves hours of life, and sometimes days.

2. JWT structure: header.payload.signature

When you first see JWT, it looks like a “long string with dots”. And the beginner’s brain tries to file it under “unreadable, therefore magic”. Our task is to do the opposite: see that the token is three separate parts, simply glued together with dots, and each part has its own role. And most importantly: two of those three parts are ordinary JSON encoded with Base64URL.

JWT (in the classic signed variant, which is most often encountered in backend development) looks like this:

base64url(header) . base64url(payload) . base64url(signature)

You can draw it in a slightly more “human” way:

[ header ] . [ payload ] . [ signature ]

And here is an important discipline of thinking: token structure != trust in the token. The fact that a string “looks like” JWT (three parts, dots, all that) does not mean the token is correct, not forged, not expired, and actually belongs to your system. It only means: “you can try to read it”.

It is very useful to keep a small table in mind:

Part How it is stored in JWT What it means Can it be “read” as text?
1 header service information about the type/algorithm yes, after decoding
2 payload claims (data/context) yes, after decoding
3 signature the signature protecting the first two parts as JSON — no

If we use a very everyday analogy: header is the “envelope with markings”, payload is the “letter inside”, and signature is the “seal on the envelope” that is supposed to prove nobody tampered with the letter. But (and this is important) you can read the letter even if the seal has not been checked. That is why “decode” and “trust” are different actions.

3. Base64URL: how JSON turns into a string

For beginners, most of the horror around JWT is not security but visual noise. They see a sequence of characters like eyJhbGciOi... and think it is “encrypted”. In reality, it is usually just encoding (encoding) that makes the string safe for transport in URL/HTTP headers and convenient to copy. It is like packing a letter into a sturdy envelope: the letter did not become secret, it just became easier to carry around.

Most often you will encounter Base64URL, not plain Base64. The difference is small, but practical:

Feature Base64 Base64URL
“unsafe” characters + and
/
- and
_
padding often includes
=
often does not include
=
fits URLs not really yes

In Java, there are direct tools for this — no need to reinvent the wheel with regexes and homemade character tables.

A mini example: let us take the base64url part of payload and decode it into a string.

import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class JwtBase64UrlDemo {
    public static void main(String[] args) {
        // Base64URL part of the payload (usually without '=' at the end)
        String payloadPart = "eyJzdWIiOiJ1c2VyLTQyIn0";

        String json = new String(
                // Important: UrlDecoder specifically, because JWT uses Base64URL
                Base64.getUrlDecoder().decode(payloadPart),
                // Always specify the charset explicitly so we do not depend on the platform
                StandardCharsets.UTF_8
        );

        System.out.println(json); // {"sub":"user-42"}
    }
}

Notice the psychological effect: the “magical string” turns into ordinary JSON. That is the goal of today’s lecture: to learn how to make such a “magical string” boring.

4. Parsing JWT: split, checks, and “idiot-proofing”

If JWT has three parts, the first thing you want to do is token.split("."). And here Java, as usual, reminds you that it is an adult and likes regular expressions. In split(), the argument is a regex, and a dot in regex means “any character”. As a result, you will not split the string by dots, but will end up cutting it “all over the place”.

Bad example (very common):

import java.util.Arrays;

public class JwtSplitBadDemo {
    public static void main(String[] args) {
        String token = "aaa.bbb.ccc";

        // '.' in regex means “any character”, so split cuts the string almost “into nowhere”
        System.out.println(Arrays.toString(token.split("."))); // []
    }
}

Good example — escape the dot:

import java.util.Arrays;

public class JwtSplitGoodDemo {
    public static void main(String[] args) {
        String token = "aaa.bbb.ccc";

        // '\\.' is a “literal dot” for regex in a Java string
        System.out.println(Arrays.toString(token.split("\\."))); // [aaa, bbb, ccc]
    }
}

And here “idiot-proofing” starts, in a good sense. Your code that “reads JWT” should be strict right at the beginning:

public static String[] splitJwt(String token) {
    // JWT must consist of exactly three parts separated by dots
    String[] parts = token.split("\\.");
    if (parts.length != 3) {
        // The earlier and clearer we fail, the less time we spend debugging
        throw new IllegalArgumentException("JWT must have 3 parts, got: " + parts.length);
    }
    return parts;
}

This looks minor, but in practice it saves a lot of time. Because in reality you get:

- a token without a signature (header.payload);
- a token with extra dots (for example, someone concatenated two strings);
- a string where “Bearer ” was forgotten to be stripped off (and the header is no longer base64).

Strictness on input is your way to make the error understandable and early, instead of “something later blew up with 500 and everything died”.

5. header: typ, alg, and service parameters

header is the part of JWT that especially tempts beginners into wrong conclusions. It often contains the alg field, and the hand reaches out to say: “Oh, so I just need to trust what is written in alg.” No. header is technical information, but by itself it is not a “guarantee of honesty”. It can be read, but you cannot trust it without verifying the signature. Yes, it sounds philosophical, but this is real engineering discipline.

Let us take the classic header (it is very common in demo tokens):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Decode it:

import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class JwtHeaderDecodeDemo {
    public static void main(String[] args) {
        // The first part of JWT: base64url(header)
        String headerPart = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";

        String headerJson = new String(
                // Base64URL decoding: we get JSON bytes
                Base64.getUrlDecoder().decode(headerPart),
                // Convert bytes to a string as UTF-8
                StandardCharsets.UTF_8
        );

        System.out.println(headerJson); // {"alg":"HS256","typ":"JWT"}
    }
}

Now let us take the next step: parse the JSON so we can see the fields as a structure. In the Secure Content Platform API project, we already have Jackson, so we will use ObjectMapper:

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.Map;

public class JwtHeaderParseDemo {
    public static void main(String[] args) throws Exception {
        // Jackson will parse JSON into a Map so we can quickly inspect fields
        ObjectMapper objectMapper = new ObjectMapper();

        String headerJson = """
                {"alg":"HS256","typ":"JWT"}
                """;
        Map<String, Object> header = objectMapper.readValue(headerJson, new TypeReference<>() {});

        // In practice, we are usually interested in the algorithm and token type
        System.out.println(header.get("alg")); // HS256
        System.out.println(header.get("typ")); // JWT
    }
}

What is important to understand in terms of meaning:

typ usually says “this is JWT” (but it is not always required and not always useful). alg says which algorithm was used for the signature. In real systems you will also encounter kid (key id) — “key identifier”, if there are several keys. But for today’s goal, it is enough that you learned to distinguish that header is JSON and that it contains service parameters.

6. payload: claims and data readability

Payload is the place where beginners most often fall into two extremes. The first extreme: “if it is in the token, then it is secret”. The second extreme: “if I can read it, then I can trust it”. The correct position is somewhere in the middle: you can read payload (and that is normal), but you can trust it only after verifying integrity (through the signature). At the same time, the application usually takes claims precisely from payload.

To see payload with our eyes, let us take a well-known token example (a real, “nice” one, with a proper signature). It does not matter how it is signed right now — we are only reading:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Let us glue it together in code and decode the payload:

import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class JwtPayloadDecodeDemo {
    public static void main(String[] args) {
        String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
                + ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"
                + ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";

        String[] parts = token.split("\\.");
        String payloadJson = new String(
                Base64.getUrlDecoder().decode(parts[1]),
                StandardCharsets.UTF_8
        );

        System.out.println(payloadJson); // {"sub":"1234567890","name":"John Doe","iat":1516239022}
    }
}

Payload is a JSON object. It contains fields (claims). Today it is not their meaning that matters to us, but the simple fact: this is a data structure that you can decode, parse, and inspect.

And yes, payload is readable. Therefore, any “extra” personal data, internal flags, and especially things like passwordHash are bad guests in payload. If you do not want it seen, it should not be in payload (at least in a regular JWT without encryption).

7. signature: the meaning of the signature and integrity

The third part of JWT often looks like “another base64url chunk, so it must be JSON too”. And this is where it is important to stop and calmly say: no. A signature is not JSON and not human-readable data. It is the result of a cryptographic operation: essentially, a “fingerprint” that proves that header and payload have not changed since the token was issued.

At the application level, it is useful to keep this formula in mind, without diving into mathematics:

signature = Sign( base64url(header) + "." + base64url(payload), secretOrPrivateKey )

So the signature is computed not “for payload separately”, but for both parts at once (and with a dot between them!). Therefore any change to header or payload makes the signature invalid.

You can even show this without cryptography — purely logically. Suppose we have a token and we “tampered with payload” (for example, changed one letter). Then the string header.payload changed. And the signature, if it is real, should have changed too. The old signature can no longer be considered correct for the new string.

public class JwtTamperDemo {
    public static void main(String[] args) {
        String original = "aaa.bbb.ccc";
        String tampered = "aaa.changed.ccc";

        System.out.println(original.equals(tampered)); // false
    }
}

This is very primitive, but the idea is this: a token is a single object. If you changed the payload, it is already a different token. And “signature correctness” is a separate check that tells you the token was not forged.

Another observation: the signature is also usually in Base64URL. But if you decode it, you will just get bytes. That may be useful for debugging sizes, but not for “reading with your eyes”.

8. A mini tool: JwtTokenReader for debugging

In Secure Content Platform API, token strings inevitably end up in requests, logs, and error handling. And instead of copying the token into a third-party service every time, it is convenient to have a tiny utility in the code: “read the token and return the parsed header/payload”. It is important that this tool is only about reading, not about security as such — otherwise you risk accidentally building access logic on unverified data.

Let us create a compact result model using a record — this is a modern, concise way to describe a DTO in Java:

package com.example.securecontent.security.jwt;

import java.util.Map;

public record JwtDecodedToken(
        Map<String, Object> header,
        Map<String, Object> payload,
        String signature
) { }

Now, a small reader. It can: split the string into three parts, decode header/payload, parse JSON into a Map.

package com.example.securecontent.security.jwt;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;

public class JwtTokenReader {
    private final ObjectMapper objectMapper;

    public JwtTokenReader(ObjectMapper objectMapper) {
        // ObjectMapper is usually reused (it is heavy and thread-safe in typical scenarios)
        this.objectMapper = objectMapper;
    }

    public JwtDecodedToken read(String token) throws IOException {
        // 1) Strictly check the shape: there must be exactly 3 parts
        String[] parts = token.split("\\.");
        if (parts.length != 3) throw new IllegalArgumentException("JWT must have 3 parts");

        // 2) Decode JSON from the first two parts, leave the signature as a string
        return new JwtDecodedToken(
                decodeJson(parts[0]),
                decodeJson(parts[1]),
                parts[2]
        );
    }

    private Map<String, Object> decodeJson(String part) throws IOException {
        // Base64URL -> bytes -> UTF-8 string -> JSON -> Map
        String json = new String(Base64.getUrlDecoder().decode(part), StandardCharsets.UTF_8);
        return objectMapper.readValue(json, new TypeReference<>() {});
    }
}

And an example of use (for example, where you log something for debugging). Important: do not log the token in full, especially in production. But printing alg and some subject identifier (if there is one) can sometimes be useful.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class JwtLogDemo {
    private static final Logger log = LoggerFactory.getLogger(JwtLogDemo.class);

    public void debugToken(JwtTokenReader reader, String token) throws Exception {
        // We read the token only to debug its structure and fields, not to make access decisions
        JwtDecodedToken decoded = reader.read(token);

        // Print only “safe” metadata pieces, without the token itself
        log.info("JWT header.alg={}", decoded.header().get("alg"));
        log.info("JWT payload keys={}", decoded.payload().keySet());
    }
}

If you really want to “show the token in logs”, it is better to mask it:

public class TokenMasking {
    public static String mask(String token) {
        // Do not try to “smartly” mask strings that are too short or empty
        if (token == null || token.length() < 25) return "***";

        // Keep the beginning and end, hide the middle
        return token.substring(0, 10) + "..." + token.substring(token.length() - 10);
    }
}

This is trivial, but it saves you from the situation where “our logs leaked, and the logs contain real bearer tokens”.

9. Typical mistakes when parsing JWT

Mistakes around JWT for beginners are often not “about security” but about basic technique: regex in split(), the wrong decoder, incorrect string handling, and logs. Because of that, the system looks like “broken magic”, although in reality it simply received a malformed string and honestly refused to understand it.

Mistake #1: token.split(".\") instead of token.split("\\.").
This is the case where one dot turns your code into a weirdness generator. In Java, split() takes a regex, and a dot in regex means “any character”. So you get an empty array or garbage instead of three parts and spend a long time wondering why “it works sometimes and sometimes not”.

Mistake #2: use Base64.getDecoder() instead of Base64.getUrlDecoder().
JWT parts are usually in Base64URL, not plain Base64. If you use the wrong decoder, you will get an exception on the - and _ characters or on missing padding. Sometimes this appears only for some tokens, and that makes the bug especially annoying.

Mistake #3: assuming that “if there are three parts, the token is valid”.
Three parts is just the shape. Validity is a separate set of checks: correct base64url, correct JSON, and most importantly, signature and time verification. Even if the token decodes perfectly, that is not yet “truth” — it is just readable data.

Mistake #4: trying to “read the signature” as if it were payload.
A signature is not JSON. It does not contain fields like sub or roles. It is bytes used to verify the integrity of the first two parts. Yes, you can decode it into a byte array, but that is rarely directly useful to a human.

Mistake #5: log the full JWT in production “for easier debugging”.
This is a classic trap: “it is just a string”. In practice, a bearer token is an access key for the lifetime of the token. If it gets into logs, it can end up in the wrong hands. In a learning project we can allow ourselves more, but it is better not to build the habit.

1
Task
Spring Security, level 22, lesson 1
Locked
Printing Three JWT Parts
Printing Three JWT Parts
1
Task
Spring Security, level 22, lesson 1
Locked
Reading header and payload from a full JWT string
Reading header and payload from a full JWT string
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION