JWT Decoder
A paste-a-token JWT inspector: header and payload as labelled claims, registered claims explained, exp/nbf/iat judged against an injected instant, and a permanent notice that the signature is never verified.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/jwt-decoder.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "JwtDecoder" component (lucide-react for icons; no other
runtime dependency — the base64url decoding is written out rather than pulled from a library).
It decodes a JWT and explains it. It never verifies a signature, and it must never look as if
it does.
Contract
- Export a pure `decodeJwt(raw: string, options: { now: Date; leewaySeconds?: number }):
JwtDecodeResult` alongside the UI, so the reading can be used without rendering anything.
- Export a forwardRef<HTMLDivElement> component extending
Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange">.
- Props: now: Date (required), value?: string, defaultValue?: string,
onValueChange?: (token: string) => void, onDecode?: (result: JwtDecodeResult) => void,
editable = true, label = "JSON Web Token", placeholder, rows = 4, locale = "en-US",
timeZone = "UTC", leewaySeconds = 0, maxPreviewLength = 72, className.
- `now` is required and injected deliberately: a clock read during render makes the verdict
differ between the server pass and hydration, and makes the component impossible to test or
screenshot. Callers pass a server instant or a hydration-safe client one.
- JwtDecodeResult = { verdict, token, segments: string[], header, payload, signature: string,
times, problems, msToExpiry: number | null }.
verdict: "empty" | "unreadable" | "active" | "expired" | "not-yet-valid" | "undated".
header / payload: JwtSegmentReport = { raw: string; value: unknown;
object: Record<string, unknown> | null; error: string | null }.
times: { claim: "exp" | "nbf" | "iat"; seconds: number; ms: number }[] — only the claims that
parsed as real NumericDates. problems: { tone: "error" | "warning" | "note"; text: string }[].
- The component is uncontrolled by default (defaultValue seeds state) and controlled when
`value` is passed; commit() writes state only in the uncontrolled case and always calls
onValueChange. onDecode fires from an effect keyed on the memoized result, through a ref that
holds the latest callback, so an inline arrow does not re-fire the report every render.
Behavior — reading the token
- Normalize before splitting, and report every adjustment as a "note" problem rather than doing
it quietly: trim; drop wrapping double quotes (pasted out of a JSON string); drop a leading
case-insensitive "Bearer " (pasted out of an Authorization header); strip all inner whitespace
(an editor wrapped it) and say how many characters went.
- Split on ".". Exactly three segments or nothing: five segments is a JWE, and the honest answer
is that its payload is ciphertext and no decoder can show it without the key. Any other count
says a compact JWS has exactly three segments, the third of which may be empty but whose dot
cannot be missing.
- Per segment, in order, stopping at the first refusal and quoting it exactly:
1. standard base64 (+ or /) is accepted but reported as a warning; trailing "=" padding is
stripped with a note, because compact JWS is unpadded;
2. any character outside the base64url alphabet is refused, naming the character and its
1-based position;
3. a length of 4n + 1 is refused — 6 bits per character means such a length cannot be produced
by any encoder, so the segment is truncated;
4. bytes are decoded with TextDecoder("utf-8", { fatal: true }); a throw becomes "does not
decode to valid UTF-8 text";
5. JSON.parse; on SyntaxError the parser's own message is quoted verbatim, never paraphrased,
because engines word it differently and the exact position is the useful part.
Write base64url → bytes by hand (6-bit accumulator over a lookup table) instead of atob:
atob is lenient about exactly the padding and alphabet this component is being honest about.
- Partial decoding is a feature: a broken payload must not blank the header. Each panel renders
its own error in place, the rest of the page keeps working.
- A header that decodes to anything but a JSON object is an error (a JOSE header must be an
object). A payload that decodes to a non-object is a warning: it is a legal JWS payload but
not a JWT claims set, so it is shown as a value with no claims to read.
Behavior — the time verdict (the maths)
- exp / nbf / iat are NumericDate: a JSON number of SECONDS since 1970. A non-number, or a
non-finite one, is a warning and yields no instant. |seconds| beyond 8.64e12 is outside the
range a JS Date can hold and is refused as a time. A value above 1e11 still parses but earns
a warning that it looks like milliseconds — the single most common issuer bug.
- Let nowMs = now.getTime() and leewayMs = max(0, leewaySeconds) * 1000. Following RFC 7519,
exp refuses the token ON or after its instant, nbf refuses it before:
expired = exp exists AND nowMs >= exp.ms + leewayMs
early = nbf exists AND nowMs < nbf.ms - leewayMs
verdict = unreadable ? "unreadable" : expired ? "expired" : early ? "not-yet-valid"
: (exp or nbf exists) ? "active" : "undated".
- Extra honest checks: nbf at or after exp means the token has no usable window at all; iat
beyond nowMs + leewayMs means the issuer clock and yours disagree. Both are warnings, not
errors — the token still decodes.
- "undated" is a first-class verdict, not a silent success: no exp and no nbf means nothing
inside the token ever ends it, and whoever accepts it owns that decision.
- Absolute dates print through one Intl.DateTimeFormat built from explicit components
(year/month/day/hour/minute/second + timeZoneName, hour12 false — dateStyle/timeStyle cannot
be combined with timeZoneName), defaulting to UTC so a screenshot means the same thing in
every office. Relative phrases come from Intl.RelativeTimeFormat with numeric "auto",
coarsest-fit from seconds up to years.
Rendering
- Layout, top to bottom: the paste field (only when editable) → verdict banner → the permanent
not-verified notice → the problem list → the raw segment strip → header and payload panels
side by side → the signature strip.
- The verdict banner carries a headline, one sentence of detail, and a line naming the instant
it was judged against plus the skew allowance when leewaySeconds is non-zero. "Active" never
gets a green tick: it reads "Inside its time window" with a clock, because a tick next to an
unverified token is a lie.
- The not-verified notice renders in EVERY state, including empty, and is phrased as a property
of the tool, not as a result: decode, never validate; anyone can edit a payload and re-encode
it; only a verifier holding the key can say a token is genuine.
- Claim rows: the registered claims first in spec order (iss, sub, aud, exp, nbf, iat, jti for
the payload; typ, cty, alg, enc, zip, kid, jku, x5t, crit for the header), then everything
else in JSON order. Each row shows the key, the value, a one-line plain-English meaning when
the claim is registered, and for exp/nbf/iat the absolute instant plus its relative distance —
turning destructive on exactly the claim that produced a refusal.
- Values: strings render through JSON.stringify so quotes and escapes stay honest; objects and
arrays collapse to a one-line preview cut at maxPreviewLength with a disclosure that opens the
pretty-printed JSON. Each panel header also has a Raw toggle for the whole segment and a copy
button.
- Copy has a visible outcome, always: success flips the icon to a check with a sentence,
a missing Clipboard API or a refused write opens the Raw JSON block so the text is on screen
to select and says so. A silent no-op reads as a broken button.
Keyboard and ARIA
- Everything is a native control, so the tab order is the whole keyboard map: textarea → clear ✕
→ per panel (Raw, copy, then each nested-value disclosure). Enter/Space activate buttons;
the textarea keeps every native editing key, and nothing hijacks Escape so a surrounding
dialog still closes.
- Root is role="group" with aria-label. Each panel is a section labelled by its heading id.
Disclosures use aria-expanded plus aria-controls pointing at the region ONLY while it is open
(an IDREF to a node that does not exist is worse than none), and carry an aria-label that
names the claim they belong to. The Raw toggle remembers being open across edits, so both of
its attributes are derived from `open AND still decodable`, never from the remembered intent —
otherwise editing a token into a broken one leaves aria-expanded true over an empty panel.
- Buttons that cannot act (nothing decoded to copy or to show raw) go aria-disabled with the
guard in the handler — never the native disabled attribute, which blurs a control the instant
it goes inert. The panels always render for the same reason: nothing under the user unmounts
when a token changes.
- The clear ✕ is the one control that does unmount itself, so it focuses the textarea FIRST and
clears second; a control that vanishes under the caret drops focus onto the body.
- Two polite sr-only status regions, both aria-atomic: one carries the verdict headline plus a
problem count only — identical text across keystrokes means it is announced once per real
outcome, not once per character — and one carries the copy result.
Cleanup and one-shot guarantees
- One timeout in the whole component, for the copy message: the handler reads the pending timer
ref and clears it before arming the next one, so a second press cannot leave the first timer
alive to wipe the newer message. It is cleared on unmount, alongside a mounted ref that stops
the clipboard promise resolving into a dead tree.
- Decoding is a pure useMemo over (text, nowMs, leewaySeconds): no interval, no rAF, no
listener, no observer, nothing to cancel — and no clock read anywhere in render.
Rendering & styling
- Semantic tokens only: bg-card / bg-background / bg-muted surfaces, border and border-dashed,
text-foreground and text-muted-foreground, text-destructive with border-destructive/40 plus
bg-destructive/5 for a refusal, ring-ring focus-visible rings, hover:bg-accent on icon
buttons. The three compact-JWS segments are tinted var(--chart-1..3) — and only when there are
exactly three, since colouring a JWE that way would name its parts wrongly; leaf values use
var(--chart-4) for strings, var(--chart-5) for numbers, var(--chart-3) for booleans, and
italic muted for null. Zero hex, zero rgb().
- Merge every className through cn(). The only motion is the disclosure chevron rotating and a
few colour transitions, all carrying motion-reduce:transition-none: the rotation is a state,
the easing is decoration, and nothing here needs an animation in order to work.
- Long base64url wraps with break-all rather than scrolling away, panels are min-w-0 inside a
sm:grid-cols-2 grid, and JSON blocks scroll horizontally instead of reflowing code.
Customization levers
- Claim dictionaries: PAYLOAD_CLAIMS and HEADER_CLAIMS are plain Maps of claim → one-line
meaning, and PAYLOAD_ORDER / HEADER_ORDER decide what floats to the top. Add your own
organisation claims (roles, tenant, scope) to teach the panel your vocabulary.
- Colour: SEGMENT_TONES and VALUE_TONES are two small lookup tables of var(--chart-N) — swap
them to re-map the whole page.
- Density: drop the Raw toggle, the copy buttons, or the segment strip and nothing else breaks;
raise or lower maxPreviewLength to decide how much of a nested claim shows before it collapses.
- Time policy: leewaySeconds is the clock-skew knob, timeZone and locale decide how instants
read. Passing a different `now` is how you preview "what will this token look like in an hour".
- Read-only embedding: editable={false} drops the paste field and inspects the token it was
handed, for a debug drawer that displays a token rather than accepting one.
- What must NOT be levered away: the not-verified notice, and the absence of any code path that
could be mistaken for verification. If your app needs a verdict on authenticity, it comes from
a server holding the key — feed it in as separate UI, never as a state of this component.Concepts
- Injected instant — the verdict is measured against a
nowthe caller supplies, never a clock read during render. That is what keeps the server pass and hydration agreeing, makes every card on this page reproducible, and lets an app ask what a token will look like an hour from now. - NumericDate and the leeway window — exp, nbf and iat are seconds, not milliseconds, and the spec refuses a token ON or after exp and before nbf.
leewaySecondswidens both ends by the same amount for clock skew, and the banner names the allowance instead of letting it be an invisible fudge. - Decode is not verify — the notice is permanent, in every state, and phrased as a property of the tool rather than a result. A decoder that reads like a validator is exactly how a forged payload gets trusted, so there is no code path here that could be mistaken for a signature check.
- Honest refusal — a bad character is named with its position, a 4n+1 length is explained as truncation, and a JSON failure is quoted from the parser word for word. Guessing at a friendlier message throws away the one detail that tells you where the token was cut.
- Reported normalization — a Bearer prefix, wrapping quotes and injected line breaks are stripped so a real paste just works, but each adjustment is listed as a note. Silently rewriting the input would undermine every other claim this panel makes.
- Partial decode — the header surviving a broken payload is the point: panels fail independently, so a token that dies halfway still tells you which key signed it and which algorithm it claims.
JSON Diff
A structural diff of two JSON values as one tree — added, removed and changed keys marked by sign and wording as well as colour, changed leaves showing before → after inline, unchanged subtrees folded behind counts, and arrays paired by index or by an identity field.
Hexdump Viewer
A windowed hex dump whose offset gutter, hex pane and ASCII pane share one selection, with 8/16/32 bytes per row and copy of the selected range as hex or text.