DEV Community

Cover image for Verifying a JWT Signature: HS256 vs RS256 (and What exp Actually Means)
Skojio Community
Skojio Community

Posted on • Originally published at skojio.com

Verifying a JWT Signature: HS256 vs RS256 (and What exp Actually Means)

If you've ever pasted a JWT into a debugger just to see three chunks of jumbled text and a green tick you didn't fully trust, this is for you. Decoding a JWT is the easy 90% of the job — reading the header and payload back as JSON takes one line of code. The part that actually matters for security — verifying the signature — is where most explanations either wave their hands or skip straight to "just use a library." This walks through what a JWT's three segments actually contain, how exp and the other standard claims work, and what it genuinely means to verify a signature for the two families you'll meet most often: HMAC (HS256) and public-key (RS256/ES256).

What a JWT actually is: three segments, not one blob

A JSON Web Token is three base64url-encoded pieces joined by dots:

header.payload.signature
Enter fullscreen mode Exit fullscreen mode
// header
{ "alg": "HS256", "typ": "JWT" }

// payload
{ "sub": "user_42", "iat": 1751500000, "exp": 1751503600 }
Enter fullscreen mode Exit fullscreen mode

Base64url is not encryption — it's a reversible text encoding, the same way a QR code isn't a secret. Anyone holding a JWT can decode the header and payload themselves with nothing more than atob() and a JSON parser. This surprises people the first time they realise it: a JWT's contents are never confidential, only (potentially) tamper-evident. If you need to hide the payload's contents from the bearer of the token, a JWT is the wrong tool — that's what encrypted JWTs (JWE) or an opaque, server-side session token are for.


Never put anything you don't want the token holder to read in a JWT's payload — session IDs, role names, and user IDs are fine; raw passwords, unmasked card numbers, or anything you'd wince at appearing in a browser's devtools are not.

Reading the standard claims: what exp, iat, nbf, aud, iss actually mean

The payload can hold whatever your application needs, but RFC 7519 defines a handful of registered claims most real-world tokens use:

Claim Meaning
exp Expiry — Unix timestamp after which the token must be rejected
iat Issued-at — Unix timestamp when the token was created
nbf Not-before — Unix timestamp before which the token must NOT be accepted yet
aud Audience — who the token is intended for
iss Issuer — who created the token
sub Subject — usually the user or account the token represents
jti Unique token ID, often used to detect replay

The three time-based claims (exp, iat, nbf) are all plain Unix seconds — no timezone, no ISO string, just an integer. That's exactly why "is my token expired?" is a more annoying question to answer by eye than it should be: you have to convert the number to a date yourself, and get the units right (seconds, not milliseconds — a common off-by-1000 bug). A 1751503600 sitting in a payload tells you nothing at a glance; converted, it's a specific date and time you can actually reason about.

Decode first, verify second — and why the order matters

Given a token, there are two genuinely different questions you can ask:

  1. "What does this token claim?" — answered by decoding. Split on the two dots, base64url-decode the header and payload, done. No key required, no trust implied.
  2. "Should I believe what it claims?" — answered by verifying. This requires the correct key and tells you whether the header and payload have been altered since they were signed.

A worryingly common mistake is reading claims out of a decoded-but-unverified token and acting on them — trusting a role: admin claim, say, without ever checking the signature. If the payload is just base64-decoded JSON, it's exactly as trustworthy as a text file the user handed you: anyone can write {"role":"admin"} into it. Decoding without verifying tells you what a token says; only verification tells you whether it's true.

Verifying HS256: the shared-secret family

HS256 (and its longer siblings HS384, HS512) use HMAC — a single shared secret that both the issuer and the verifier must hold. Verification recomputes the HMAC over the token's first two segments (base64url(header) + "." + base64url(payload)) using that secret, and compares the result byte-for-byte against the signature segment.

expected_signature = HMAC-SHA256(secret, header + "." + payload)
Enter fullscreen mode Exit fullscreen mode

If expected_signature matches the token's actual signature segment, the token is genuine and untampered — provided the secret itself was never leaked. That's HS256's whole trust model in one sentence: anyone who holds the secret can both sign new tokens and verify existing ones, which is fine for a single backend service checking its own tokens, and a liability the moment that secret needs to be shared across multiple services or handed to a third party.

Verifying RS256 and ES256: the public/private-key family

RS256 (RSA) and ES256 (ECDSA) split signing and verifying into two different keys. A private key signs; a completely separate, non-secret public key verifies. This solves HS256's sharing problem cleanly: an identity provider can publish its public key openly, and any number of services can verify tokens against it without ever holding anything sensitive.

signature = Sign(private_key, header + "." + payload)     // issuer only
is_valid  = Verify(public_key, header + "." + payload, signature)  // any verifier
Enter fullscreen mode Exit fullscreen mode

RS256 uses RSA keys (typically PEM or JWK format); ES256 uses elliptic-curve keys, which are considerably smaller for equivalent strength — a practical reason ES256 has become popular where token size matters (URLs, headers, mobile). Either way, the shape of the trust model is identical: verifying a token never requires the private key, only the issuer's public key, which by design is safe to publish.

Two JWT security traps worth knowing by name

alg: none. The JWT spec allows a header to declare alg: none, meaning the token is deliberately unsigned — legitimate for some internal testing scenarios, but catastrophic if a verifier ever accepts one from the outside world without an explicit, deliberate opt-in. Some older or misconfigured libraries did exactly that: they read alg from the token itself and, on seeing none, skipped verification entirely — letting an attacker forge any payload they liked with zero key material. Any tool or library you use to inspect JWTs should flag alg: none loudly rather than quietly decode it as if nothing were wrong.


If you ever see alg: none on a token you didn't generate yourself for testing, treat it as a red flag, not a curiosity — it means the token carries no proof of integrity at all.

RS256-to-HS256 key confusion. This one is subtler. If a verifier trusts the alg value written in the token itself, an attacker can take a service's own — publicly available — RSA public key and resign a token with HS256, using that public key's bytes as the HMAC secret. A verifier that blindly does "whatever alg says, use this key" can end up HMAC-verifying against a key it never intended to be a shared secret, and pass. The fix lives entirely on the verifying side: pin the algorithm you expect (e.g. "this service only ever accepts RS256") rather than trusting the token to tell you what algorithm to use.

Try it: decode, verify, and build test tokens in one page

Skojio's JWT Decoder, Verifier & Encoder covers the whole loop described above in one page, entirely in your browser:

  • Paste a token to see its header and payload decoded and every standard claim humanised — including exp/iat/nbf converted to local date/time, not just a raw integer
  • An at-a-glance validity banner tells you expired / not-yet-valid / valid, with a relative time ("expired 3 days ago")
  • Verify HS256/384/512 against a shared secret, or RS256/384/512 and ES256/384/512 against a public key (PEM or JWK) — all via the browser's native WebCrypto, nothing sent anywhere
  • alg: none tokens get a non-dismissible warning instead of a silent pass, and pasting a public key against an HMAC-alg token triggers an explicit key-confusion warning
  • An Encode & Sign mode lets you build and sign a test token from scratch — handy for reproducing an expiry bug or a malformed-token scenario without reaching for a CLI

It doesn't claim to be more private than every other JWT tool out there — several others are also client-side. What it does offer is a complete, vendor-neutral page with no account, no marketing chrome, and both directions (decode/verify and encode/sign) covered for all nine mainstream algorithms in one place. If you need a shared secret for HS256 testing, Skojio's Password Generator will generate one; if you're inspecting the base64url encoding itself rather than a full token, Base64 Encoder/Decoder is the standalone version of that same step.

  • A JWT's header and payload are base64url-encoded, not encrypted — anyone holding the token can read them, so never put secrets in the payload
  • Decoding tells you what a token claims; only verifying its signature tells you whether to believe those claims
  • HS256 uses one shared secret for both signing and verifying; RS256/ES256 split signing (private key) from verifying (public key), which scales better across multiple services
  • exp, iat, and nbf are plain Unix-second timestamps — convert them to check expiry rather than eyeballing the raw integer
  • Watch for two specific traps: alg: none tokens that carry no signature at all, and RS256-to-HS256 key confusion, where a verifier that trusts the token's own alg field can be fooled into HMAC-checking against a public key

Top comments (0)