Developer Utilities

JWT Decoder

Decode JWT tokens and inspect header, payload and signature.

🔐

Paste JWT Token to decode

JWT consists of three parts: Header.Payload.Signature

What is JWT Decoder?

JWT (JSON Web Token) is a standard for secure information transmission. This tool decodes JWT tokens to view their contents.

JWT Structure

  • Header: Algorithm and token type
  • Payload: Claims and data
  • Signature: Verification signature

Use Cases

  • API debugging
  • Authentication troubleshooting
  • Token inspection

Splits a JWT compact token into its three Base64URL segments, decodes the header and payload as JSON, and renders exp, iat and nbf as local dates with a relative-time hint, marking the token expired or valid against your own clock. Intended for debugging a token an API has just rejected.

Three Base64URL segments, and only two of them are JSON

A JWS compact token (RFC 7515 §7.1, reused by JWT in RFC 7519 §7.1) is BASE64URL(header) + "." + BASE64URL(payload) + "." + BASE64URL(signature), with every segment stripped of = padding. The tool maps - back to +, _ back to /, restores the missing padding and calls atob; only after that does JSON.parse see bytes. Both JSON segments are UTF-8, so one Chinese character inside a name claim costs three bytes. The third segment is a different animal: it carries raw signature bytes, which is why the built-in example fits 32 bytes into 43 characters — exactly one SHA-256 digest, never parseable as JSON. Unpadded Base64URL cannot yield a segment whose length is 4k+1, so a segment that does was truncated or pasted with a stray character.

Signature length fingerprints the algorithm

Because the signature is unpadded Base64URL over a fixed-size value, its length identifies the algorithm family before you read alg. HMAC-SHA256 produces 32 bytes and 43 characters, HS384 48 bytes and 64 characters, HS512 64 bytes and 86 characters. RS256 and PS256 both emit one modulus-length value: 256 bytes and 342 characters on a 2048-bit key, 512 bytes and 683 characters on 4096 bits. ES256 is 64 bytes — r and s concatenated as fixed 32-byte integers — giving 86 characters, while an OpenSSL-style DER signature is 70 to 72 bytes and begins with 0x30; pasting DER bytes into a JWS yields a token that looks intact and fails every verifier. Length alone cannot separate RS256 from PS256, so trust alg there. This tool prints the segment untouched and does no keyed check.

exp, iat and nbf are seconds, and only exp gets compared

NumericDate in RFC 7519 §2 counts seconds from 1970-01-01T00:00:00Z, ignoring leap seconds, so the component multiplies each value by 1000 before constructing a Date. Writing a JavaScript millisecond timestamp is the classic mistake: 1516239022000 renders as the year 50017, and because 1.5e12 is not smaller than the current 1.79e9 the badge still reads valid. The status line performs one strict exp < now test with no leeway; nbf is drawn as a date but never compared, and aud/iss are shown as plain strings with nothing to match them against. A payload without exp stays valid forever, even though an access token that never expires is what rotation policies exist to prevent.

Worked example

Decoding the token behind the Load Example button

Input
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MjQyNjIyfQ.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
Output
header: {"alg":"HS256","typ":"JWT"} (27 bytes) | payload: {"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022,"exp":1516242622} (85 bytes) | signature: 43 chars = 32 bytes, starting 28c505b0

The first segment is 36 characters of unpadded Base64URL, which is exactly the 27 bytes of the header JSON; the payload segment is 114 characters for 85 bytes, and its exp 1516242622 is 2018-01-18T02:30:22Z, so the status card marks the token expired. The signature segment expands to 28c505b0...577d, one 32-byte HMAC-SHA256 tag that the tool displays verbatim without recomputing it from the header and payload.

Limits and pitfalls

  • No verification happens: there is no secret, private key or JWKS input, so a token whose payload was rewritten decodes identically to a genuine one and the signature card only echoes the segment.
  • Exactly three dot-separated parts are accepted; a five-part JWE in RFC 7516 compact form is reported as invalid, while an unsecured alg: none token ending in a trailing dot passes the split and shows an empty signature.
  • Expiry is judged against the browser clock with a strict exp < now comparison and no clock-skew leeway, and nbf is rendered but never enforced, so a token that is not yet valid still shows as valid.
  • Claims are percent-decoded as UTF-8 and fall back to a Latin-1 read when those bytes are invalid, so a mis-encoded claim renders as mojibake in a card that otherwise looks normal.
  • Input handling is literal: only a leading Bearer plus whitespace is stripped, and the textarea sets no length cap, so a token still wrapped in quotes from a .env file fails the three-part check instead of decoding.

Related tools

When it goes wrong

The card shows a green check and "Token Valid", but the API still answers 401.

CauseThe badge runs exactly one test, payload.exp ? payload.exp < now : false, with no clock-skew leeway. There is no secret, private key or JWKS field anywhere in the component, so the Signature card is a verbatim echo of the third segment, and aud, iss, jti are printed as ordinary strings with nothing to compare them against. "Valid" therefore means one thing only: exp has not passed yet.

FixTreat the badge as an expiry hint, not a verification result. Put the token through a keyed verifier — jwt.io with the secret or PEM, jose jwtVerify, or jsonwebtoken.verify — and compare aud with the audience your API expects. Since the comparison here has zero tolerance, also check that the browser clock and the server clock agree to the second.

The payload says "exp": 0, the badge is still green, and the timeline card has no expiry row at all.

CauseThe expiry check is a truthiness test rather than a key check, so 0 reads as "no exp": isExpired falls back to false and expiresAt stays undefined, which is why the status card prints no date and the timeline card skips the row. The payload card does the arithmetic anyway, multiplying 0 by 1000 and rendering the local form of 1970-01-01T00:00:00Z right next to the green check.

FixRead exp: 0 as "expired since 1970" and ignore the badge for that token. The broader rule belongs in your verifier, not here: a payload with no exp renders as valid forever on this page, so make exp a required claim on the server and reject tokens that lack it.

The expiry time shown here is eight hours ahead of the UTC stamp in my server log.

CauseformatDate calls toLocaleString with a locale but never a timeZone option, so the wall clock comes from the browser zone and the zone name is never printed. exp itself is a UTC second count: 1516242622 is 2018-01-18T02:30:22Z, which a machine on UTC+8 renders as 10:30:22 with no clue that a shift happened.

FixCompare numbers, not rendered strings: put exp next to date +%s, or read the relative "5 hours ago" line, which carries no zone. Only trust the absolute timestamp once you know which zone produced it — the timezone is the browser, not the token issuer.

A Chinese UI throws an English error: JWT 解析失败:Failed to execute 'atob' on 'Window': The string to be decoded is not correctly encoded.

CauseThe whole decode sits in one try/catch that only prefixes a localized sentence onto the browser message, so the exception text stays in the DOMException wording. atob rejects anything outside its alphabet, and the only cleanup before it is the - to +, _ to / swap plus padding. The usual trigger is pasting the whole Authorization: Bearer eyJ... line: only a leading Bearer plus whitespace is stripped, so the header name and its colon stay inside segment one.

FixPaste the three dot-separated segments and nothing else. The exception type is the diagnosis: an atob error means a non-Base64 character survived, while a JSON.parse error such as Unexpected token or Unexpected end of JSON input means the bytes decoded fine but the segment was edited, truncated, or left empty.

The JSON I copied from the Header card is not the same text that was inside the token.

CauseThe copy button serialises the parsed object with JSON.stringify(obj, null, 2), so you get a reconstruction: key order follows JSON.parse, number literals are rewritten (1.516239022e9 comes back as 1516239022), escaped characters come back as the characters themselves, and a claim that appears twice keeps only the last value because JSON.parse overwrites the earlier one.

FixWhen you need the original bytes, cut the segment out of the input box by hand and use the cards only for reading. If a payload carries a duplicated claim, do not assume every reader picks the same copy — this page shows the last one, so verify what your own parser keeps instead of trusting the card.

Six situations, and the point where decoding stops being verification

The situationJWT decoder on this pageWhat to reach for instead
One token is in front of you and you need to see what is inside itPaste it, with or without the Bearer prefix, and the three segments split immediately: header and payload as JSON, exp, iat and nbf converted to your browser clock with a relative-time line. Nothing to install, and no key to give it — which is also the limit of what it can tell you.If the question is "does the signature match", you need a debugger with a key field: jwt.io takes a secret, a PEM or a JWKS URL and reports verified or invalid. This component has no such input, so it can never answer that.
The token has five segments, or wraps another JWT inside a claimThe split is literal: token.split(".") must produce exactly three parts or the whole thing is reported as invalid and no content is shown. A JWE in RFC 7516 compact form hits that branch every time.Use jose compactDecrypt with the decryption key, or log the claims after your service has already decrypted them. An encrypted token is not readable by any decoder, and a tool that claims otherwise is guessing.
A few hundred tokens to check against a log exportOne paste, one token. The relative-time line is measured from the clock in front of you, so a token from an older log reads "1 days ago" — literally, since the string is built from the raw day count with no pluralisation — whatever was true at the time.Script it, because the operation is small: take t.split(".")[1] and decode — in Node Buffer.from(seg, "base64url"), in Python base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4)) — then compare each exp against the timestamp in the log line. A script also leaves a diff you can re-run.
You need to assert aud, iss or exp in CI or on the serverThere is no field for an expected value. aud and iss render as ordinary strings, nbf is drawn as a date but never compared, and only exp gets the one strict test. The page is a viewer, so it cannot pass or fail a token.Use a verifier that takes the expectations: jsonwebtoken.verify with an algorithms list, audience and issuer, or jose jwtVerify plus createRemoteJWKSet for rotating keys. Pin the algorithm list explicitly, otherwise alg downgrade stays open.
You just want one claim inside your own code, no dependencyThe result lives in a browser tab and has to be copied back into the editor, so it is fine for a one-off look and wrong as the data path between two systems.Three lines of stdlib do the same decode: split on the dot, pad the second segment to a multiple of four, parse it as JSON. The only work this page adds is the date conversion, and that is what datetime handling in your language already does.
You suspect the algorithm was swapped, e.g. the alg claim rewritten to none or HS256The alg value is only used to look up a one-line description such as HMAC-SHA256 (Symmetric). It is never matched against a key type or an allow-list, so a downgraded token renders as calmly as a genuine one.Force an allow-list on the verifying side, and when checking by hand recompute with the key family the alg implies: an HMAC secret for HS*, a public key for RS* and PS*. This page will show you which family was claimed, which is the input to that check, not the check.

Frequently Asked Questions

Does decoding a JWT verify it?
No. Decoding is just Base64URL plus JSON parsing — anyone can read the payload. Verification requires checking the signature with the key and validating claims such as exp, nbf, iss, and aud. Never trust a decoded-but-unverified token.
Is it safe to paste my token here?
Decoding runs entirely in your browser; the token is not uploaded. Keep in mind that a JWT payload is encoded, not encrypted — anyone holding the token can read it the same way, so treat JWTs like credentials.
Why does my token show as invalid or expired?
The usual reasons: exp has passed, the clock skew between client and server is too large, or aud/iss do not match what your backend expects. Decode the payload and compare each claim against the server configuration.

Content reviewed: