Last month I looked at the login endpoint of an internal system for a colleague. The front end was getting a 401, and the Authorization header in the capture was Bearer followed by a long string split into three sections by ., with no readable words visible by eye. He wanted only two things: when this string expires, and whether role contains admin. I split the two questions first: what can be read and whether the server accepts it are two separate lines, and mixing them makes debugging impossible.
It is encoding plus signature, not encryption
The first two sections are base64url encoding, and anyone can restore them to JSON without a key; the third section is the signature, which can only be verified with the symmetric key (the HS family) or the matching public key (the RS family). These two layers often get compressed into the sentence it is encrypted so you cannot see it, which is the opposite of the truth: you cannot see it only because you have not decoded it, and whoever holds the string can read every field. The three section lengths left by the sample, copied side by side:
Header : eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload : 264 base64url characters
Signature : 43 base64url characters
Three sections compared: characters, bytes, readability
| Section | base64url characters | After decoding | Readable | Value in this sample |
|---|---|---|---|---|
| Header | 36 | 27 bytes / 27 characters | Yes, no key needed | {"alg":"HS256","typ":"JWT"} |
| Payload | 264 | 198 bytes / 194 characters | Yes, but readable does not mean trustworthy | iss, sub, aud, name, role, vip, iat, nbf, exp, jti |
| Signature | 43 | 32 bytes / 30 characters | Reveals no intent | Sample key your-256-bit-secret |
The 198 bytes and the 194 characters on the payload row are not the same number: the value of name is the Chinese name Zhang San, and Chinese takes more bytes than characters in UTF-8, while role decodes to ["admin","editor"]. Decoding base64url by hand goes wrong the moment you add up the alphabet table, so I hand this step to the JWT Decoder: it reports bytes and characters for each section and explains every registered claim, and both parsing and verification happen in the browser with no network request from the page.
iat, nbf and exp are all UTC seconds
- The valid sample has
iatandnbfboth at 1792402758, which is 2026-10-19 09:39 UTC; the two values are equal, so it takes effect on issue. - The
expof the same section is 1861919999, which is 2028-12-31 23:59 UTC, leaving 802.8944907407407 days relative to when the script ran. - The expired sample has
iat1748593800 for 2025-05-30 08:30 UTC andexp1748766600 for 2025-06-01 08:30 UTC. - That section's
expis 506.7513310185185 days in the past relative to when the script ran, and yet the signature is still valid.
When converting, do not feed seconds to a date function that expects milliseconds; the same second produces both a UTC and a local line, so confirm which one you are reading. To restore the content of a section use Base64 Encoder and Decoder, and to turn seconds into a readable date use Timestamp Converter.
A passing signature and an unexpired token are two different things
In the expired sample the signature section is still 32 bytes, and recomputing the HMAC with the sample key your-256-bit-secret still reports pass. The signature locks down one thing: the first two sections have not changed by a single character since issue. It carries no notion of time, so a server that verifies the signature without checking exp will happily accept a token signed long ago. iss and aud likewise have to be compared by the server; the tool will not check them for you, and authorisation decisions are not its job at all.
Three edge cases, given away by length first
| Sample | alg | kid | Signature bytes | Verification result printed by that run |
|---|---|---|---|---|
| Valid | HS256 | none | 32 | Pass (secret = your-256-bit-secret) |
| Expired | HS256 | none | 32 | Pass, but exp falls on 2025-06-01 08:30 UTC |
alg=none | none | none | 0 | There is no signature to verify |
| Payload is not JSON | HS256 | none | 32 | Mismatch |
| RS256 | RS256 | demo-key-1 | 256 | Pass (using the public key built into the page) |
The alg=none row deserves the most attention: the string is still cut into three sections, and the last one decodes to 0 bytes, which means there is no signature. The risk lies in old server-side libraries verifying according to the algorithm the token declares: change the payload, set the algorithm to none, blank the signature, and validation is bypassed. The payload that is not JSON decodes to the literal text "this is a payload that is not JSON", but the structure fails and verification reports a mismatch; the script prints the recomputed signature 4prUH45MQAmbRVmdqHYpEsc2OzZ5OM0TEgApoZOXywI right after that row, and comparing it with the one in the token shows who touched what.
The strength of a symmetric key can be calculated
In the HS family one key signs and verifies, and key length decides whether it survives an offline brute force. The sample key your-256-bit-secret is only a demo placeholder: 26 lowercase letters taken 8 at a time gives 208827064576 candidates, which at a million tries per second is exhausted in 58.01 hours; widen the alphabet to upper and lower case plus digits, 62 characters, taken 12 at a time, and the space is 3.2262667623979e+21, which at a billion tries per second still takes 102421.2 years. The first is a weekend's work; the second is the first one you can just about rely on.
A few questions I did not settle cleanly at the time
Q: Is this string encrypted, and who can see what is inside?
It is not encrypted. The first two sections are only base64url encoding, and anyone who has the string can restore the JSON; it protects against tampering, not against reading. Phone numbers, passwords and ID numbers should not be in the payload at all, only an ID that the server looks up; the page warns when a key name matches those words, but stopping a token that has already been signed is not something it can do.
Q: What is the difference between base64 and base64url?
The alphabet differs: base64url maps + and / to - and _ and omits the trailing = padding, so a whole section can sit inside a URL and a header. The JSON in the common example payload is 55 characters, and once encoded as base64url it is 1.345 times the original.
Q: Does the server accept an expired token whose signature passes?
It depends on whether the server checks. The expired row is the typical combination: the recomputation matches, verification passes, and exp is 2025-06-01 08:30 UTC. Expiry, issuer, audience and revocation are separate checks, and missing one can let the token through.
Q: Should I choose HS256 or RS256?
When you sign and verify yourself, the HS family is simpler, at the cost of every verifier holding the same key; when the public key has to go to several verifiers, use RS or ES to avoid sharing a secret, at the cost of that 256-byte signature in front.
Back to my colleague's two questions. The expiry was read from the UTC line of exp, not guessed from the error code; whether role contains admin only tells you the issuer wrote it that way, and whether the endpoint accepts it is still up to the server. My trade-off now is to keep only an ID and a list of role names in the payload: every extra field is another surface for leakage, and a signature only protects against tampering, not against reading.