Token Anatomy
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Half the internet hands your browser a JWT and hopes you never look inside, which is a shame — the first two thirds are not encrypted at all, just ordinary JSON wearing a base64url coat. This page pulls one apart in front of you: the three dot-separated segments stay colour-linked to their decoded halves, every claim gets a plain-language note instead of a spec citation, and exp becomes a live countdown rather than a ten-digit number you have to paste into a converter. The samples are minted and HMAC-signed in your tab by a SHA-256 written out longhand in the file, so nothing you type ever leaves the page. Start with about to expire and leave the tab alone for a minute — the lamp flips to a red EXPIRED while you watch — then type rosewood-demo-secret into the shared-secret box to see the difference between reading a token and trusting it.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Token Anatomy — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
color-scheme: light; /* so the textarea caret, scrollbars and form chrome follow the theme */
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
:root[data-theme="light"] { color-scheme: light; }
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ---- segment palette: header / payload / signature. No green here — green is earned. ---- */
:root {
--c-h: #2B5CA8; --c-h-bg: #E1EAF8; --c-h-edge: rgba(43,92,168,.55);
--c-p: #6D3FA6; --c-p-bg: #EDE3FA; --c-p-edge: rgba(109,63,166,.55);
--c-s: #0E6C7C; --c-s-bg: #DCEEF2; --c-s-edge: rgba(14,108,124,.55);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--c-h: #8FB6F5; --c-h-bg: #1C2E4C; --c-h-edge: rgba(143,182,245,.7);
--c-p: #C4A3F1; --c-p-bg: #2C2148; --c-p-edge: rgba(196,163,241,.7);
--c-s: #6FD3E0; --c-s-bg: #14323A; --c-s-edge: rgba(111,211,224,.7);
}
}
:root[data-theme="dark"] {
--c-h: #8FB6F5; --c-h-bg: #1C2E4C; --c-h-edge: rgba(143,182,245,.7);
--c-p: #C4A3F1; --c-p-bg: #2C2148; --c-p-edge: rgba(196,163,241,.7);
--c-s: #6FD3E0; --c-s-bg: #14323A; --c-s-edge: rgba(111,211,224,.7);
}
/* ---- shell ---- */
* { box-sizing: border-box; }
[hidden] { display: none !important; } /* author display rules otherwise beat the UA [hidden] rule */
html, body { margin: 0; padding: 0; }
body { font-size: 16px; line-height: 1.55; -webkit-text-size-adjust: 100%; overflow-x: hidden; }
.wrap { max-width: 1020px; margin: 0 auto; padding: 26px 18px 36px; }
h1 { font-size: 1.5rem; line-height: 1.2; margin: 0 0 7px; letter-spacing: -.015em; }
.lede { margin: 0; color: var(--muted); font-size: .95rem; max-width: 66ch; }
.lede b { color: var(--ink); font-weight: 600; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* ---- shared bits ---- */
.k-lab {
font-family: var(--font-mono); font-size: .66rem; letter-spacing: .11em;
text-transform: uppercase; color: var(--muted);
}
.btn {
font-family: var(--font-mono); font-size: .73rem; letter-spacing: .02em;
background: var(--bg); color: var(--ink); border: 1px solid var(--line);
border-radius: 999px; padding: 7px 12px; min-height: 32px; cursor: pointer;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.btn.ghost { background: transparent; color: var(--muted); }
.btn.ghost:hover { color: var(--accent); }
.btn.won { border-color: var(--run); color: var(--run); background: var(--run-soft); }
code, .mono { font-family: var(--font-mono); }
/* ---- token panel ---- */
.panel {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 13px; margin-top: 18px;
}
.panelhead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-bottom: 10px; }
.panelhead h2 { font-size: .95rem; margin: 0; }
.samples { display: flex; flex-wrap: wrap; gap: 6px; margin-left: auto; }
.editor { position: relative; }
.layer, .editor textarea {
font-family: var(--font-mono); font-size: .82rem; line-height: 1.7;
padding: 12px 13px; border: 1px solid transparent; border-radius: 9px;
white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-all;
margin: 0; letter-spacing: 0;
}
.layer {
position: relative; background: var(--bg); border-color: var(--line);
min-height: 96px; max-height: 260px; overflow: auto; color: var(--muted);
}
.editor textarea {
position: absolute; inset: 0; width: 100%; height: 100%;
background: transparent; color: transparent; caret-color: var(--accent);
overflow: auto; resize: none; -webkit-text-fill-color: transparent;
}
.editor textarea::selection { background: rgba(128,128,128,.35); }
.editor textarea::placeholder { color: var(--muted); -webkit-text-fill-color: var(--muted); opacity: 1; }
.editor textarea:focus { outline: none; }
.editor:focus-within .layer { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.layer .seg { border-radius: 3px; padding: 2px 0; }
.layer .seg-h { color: var(--c-h); background: var(--c-h-bg); }
.layer .seg-p { color: var(--c-p); background: var(--c-p-bg); }
.layer .seg-s { color: var(--c-s); background: var(--c-s-bg); }
.layer .seg-x { color: var(--danger); background: transparent; text-decoration: underline wavy; }
.layer .dot { color: var(--accent); }
.layer .pre { color: var(--muted); text-decoration: line-through; }
body[data-hl] .layer .seg { opacity: .28; }
body[data-hl="h"] .layer .seg-h,
body[data-hl="p"] .layer .seg-p,
body[data-hl="s"] .layer .seg-s { opacity: 1; box-shadow: 0 0 0 2px var(--hl-edge, transparent); }
body[data-hl="h"] { --hl-edge: var(--c-h-edge); }
body[data-hl="p"] { --hl-edge: var(--c-p-edge); }
body[data-hl="s"] { --hl-edge: var(--c-s-edge); }
.legend { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; }
.chip {
font-family: var(--font-mono); font-size: .69rem; letter-spacing: .04em;
border: 1px solid var(--line); border-radius: 999px; padding: 6px 11px; min-height: 30px;
background: var(--surface); cursor: pointer; display: inline-flex; align-items: center; gap: 7px;
}
.chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.chip .sw { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
.chip .n { color: var(--muted); }
.chip[data-seg="h"] { color: var(--c-h); border-color: var(--c-h-edge); }
.chip[data-seg="h"] .sw { background: var(--c-h); }
.chip[data-seg="p"] { color: var(--c-p); border-color: var(--c-p-edge); }
.chip[data-seg="p"] .sw { background: var(--c-p); }
.chip[data-seg="s"] { color: var(--c-s); border-color: var(--c-s-edge); }
.chip[data-seg="s"] .sw { background: var(--c-s); }
.chip:hover { background: var(--bg); }
.privacy {
margin: 11px 0 0; padding: 9px 11px; border: 1px dashed var(--line);
border-radius: 8px; font-size: .82rem; color: var(--muted); background: var(--bg);
}
.privacy b { color: var(--ink); font-weight: 600; }
/* ---- lamps ---- */
.lamps { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 14px; }
.lamp {
border: 1px solid var(--line); border-left: 3px solid var(--line);
border-radius: var(--radius); background: var(--surface); padding: 10px 13px;
display: flex; flex-direction: column; gap: 1px; min-width: 0;
}
.lamp .v { font-family: var(--font-mono); font-size: .98rem; font-weight: 600; letter-spacing: .01em; color: var(--muted); }
.lamp .d { font-size: .83rem; color: var(--muted); overflow-wrap: anywhere; }
.lamp .d .num { font-family: var(--font-mono); color: var(--ink); }
.lamp[data-state="ok"] { border-left-color: var(--run); background: var(--run-soft); }
.lamp[data-state="ok"] .v { color: var(--run); }
.lamp[data-state="bad"] { border-left-color: var(--danger); }
.lamp[data-state="bad"] .v { color: var(--danger); }
.lamp[data-state="warn"] { border-left-color: var(--accent); background: var(--accent-soft); }
.lamp[data-state="warn"] .v { color: var(--accent); }
/* ---- flags ---- */
#flags:not(:empty) { margin-top: 12px; display: flex; flex-direction: column; gap: 7px; }
.flag {
display: flex; gap: 9px; align-items: baseline; flex-wrap: wrap;
border: 1px solid var(--line); border-left: 3px solid var(--muted);
border-radius: 8px; padding: 8px 11px; background: var(--surface); font-size: .86rem;
}
.flag .t {
font-family: var(--font-mono); font-size: .64rem; letter-spacing: .11em;
text-transform: uppercase; color: var(--muted); flex: none;
}
.flag code { font-size: .82em; background: var(--bg); padding: 1px 4px; border-radius: 4px; }
.flag[data-kind="bad"] { border-left-color: var(--danger); }
.flag[data-kind="bad"] .t { color: var(--danger); }
.flag[data-kind="warn"] { border-left-color: var(--accent); }
.flag[data-kind="warn"] .t { color: var(--accent); }
.flag .btn { padding: 3px 9px; min-height: 26px; font-size: .68rem; margin: 0 2px; }
/* ---- cards ---- */
.card {
margin-top: 14px; background: var(--surface); border: 1px solid var(--line);
border-left: 3px solid var(--seg); border-radius: var(--radius); overflow: hidden;
}
/* clip, not hidden: hidden would make the card a scroll container and kill the
sticky JSON pane below. Falls back to hidden where clip is unsupported. */
@supports (overflow: clip) { .card { overflow: clip; } }
.card:focus { outline: none; }
.card[data-seg="h"] { --seg: var(--c-h); --segbg: var(--c-h-bg); --segedge: var(--c-h-edge); }
.card[data-seg="p"] { --seg: var(--c-p); --segbg: var(--c-p-bg); --segedge: var(--c-p-edge); }
.card[data-seg="s"] { --seg: var(--c-s); --segbg: var(--c-s-bg); --segedge: var(--c-s-edge); }
body[data-hl="h"] .card[data-seg="h"],
body[data-hl="p"] .card[data-seg="p"],
body[data-hl="s"] .card[data-seg="s"] { border-color: var(--segedge); border-left-color: var(--seg); }
.cardhead {
display: flex; align-items: baseline; flex-wrap: wrap; gap: 5px 10px;
padding: 11px 14px; border-bottom: 1px solid var(--line);
}
.cardhead h2 { font-size: .97rem; margin: 0; }
.cardhead .tag {
font-family: var(--font-mono); font-size: .64rem; letter-spacing: .1em; text-transform: uppercase;
color: var(--seg); background: var(--segbg); border-radius: 999px; padding: 3px 8px;
}
.cardhead .sub { font-size: .84rem; color: var(--muted); margin-left: auto; }
.cardbody { display: grid; grid-template-columns: minmax(0,.95fr) minmax(0,1.05fr); }
.card[data-seg="s"] .cardbody { grid-template-columns: 1fr; }
.jsonwrap { padding: 12px 14px; border-right: 1px solid var(--line); min-width: 0; }
.jsonbar { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
/* On wide screens the claim list runs far past the JSON, so pin the JSON while
you read down it — hovering a claim still lights up its line up there. */
@media (min-width: 781px) {
.jsonbar { position: sticky; top: 10px; z-index: 2; background: var(--surface); padding-bottom: 3px; }
pre.json { position: sticky; top: 44px; }
}
.jsonbar .btn { padding: 4px 10px; min-height: 28px; font-size: .66rem; }
pre.json {
margin: 0; font-family: var(--font-mono); font-size: .78rem; line-height: 1.65;
white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink);
}
.jbr { display: block; color: var(--muted); }
.jline { display: block; border-radius: 4px; margin: 0 -5px; padding: 0 5px; }
.jline.lit { background: var(--segbg); }
.jk { color: var(--seg); }
.js { color: var(--ink); }
.jn { color: var(--accent); }
.jp { color: var(--muted); }
.ph { color: var(--muted); font-family: var(--font-sans); font-size: .86rem; }
.notes { margin: 0; padding: 5px 14px 10px; list-style: none; min-width: 0; }
.notes li {
display: grid; grid-template-columns: auto minmax(0,1fr); gap: 1px 10px;
padding: 8px 6px; margin: 0 -6px; border-radius: 6px; align-items: baseline;
}
/* One shared key gutter for the whole list, so every description starts at the
same x. Without subgrid each row sizes its own column and the left edge goes
ragged; the cap keeps one long custom claim from starving the text column. */
@supports (grid-template-columns: subgrid) {
.notes { display: grid; grid-template-columns: max-content minmax(0,1fr); }
.notes li { grid-template-columns: subgrid; grid-column: 1 / -1; }
.notes .k { max-width: 15ch; overflow-wrap: anywhere; }
}
.notes li.empty { display: block; }
.notes li + li { border-top: 1px solid var(--line); }
.notes li.lit { background: var(--segbg); }
.notes .k { font-family: var(--font-mono); font-size: .75rem; color: var(--seg); font-weight: 600; overflow-wrap: anywhere; }
.notes .b { min-width: 0; }
.notes .lab {
display: block; font-family: var(--font-mono); font-size: .63rem; letter-spacing: .1em;
text-transform: uppercase; color: var(--muted); line-height: 1.5;
}
.notes .t { display: block; font-size: .855rem; color: var(--ink); }
.notes .v {
display: block; font-family: var(--font-mono); font-size: .76rem; color: var(--muted);
margin-top: 4px; overflow-wrap: anywhere; line-height: 1.5;
}
.notes .v .hot { color: var(--accent); }
.notes .v .cold { color: var(--danger); }
.notes .v .good { color: var(--run); }
/* ---- signature card ---- */
.sigbody { padding: 12px 14px; display: grid; gap: 11px; }
.sigraw {
font-family: var(--font-mono); font-size: .78rem; line-height: 1.65; color: var(--c-s);
background: var(--c-s-bg); border-radius: 8px; padding: 9px 11px; overflow-wrap: anywhere;
}
.sigfacts { display: flex; flex-wrap: wrap; gap: 6px 18px; }
.fact { display: flex; flex-direction: column; gap: 1px; }
.fact .fv { font-family: var(--font-mono); font-size: .85rem; color: var(--ink); }
.sigtext { font-size: .875rem; color: var(--muted); margin: 0; max-width: 74ch; }
.sigtext b { color: var(--ink); font-weight: 600; }
.verify {
border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; background: var(--bg);
display: grid; gap: 8px;
}
.vrow { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
.verify label { font-family: var(--font-mono); font-size: .66rem; letter-spacing: .11em; text-transform: uppercase; color: var(--muted); }
.verify input {
flex: 1 1 190px; min-width: 0; font-family: var(--font-mono); font-size: .82rem;
background: var(--surface); color: var(--ink); border: 1px solid var(--line);
border-radius: 8px; padding: 8px 10px; min-height: 34px;
}
.verify input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.vhint { font-size: .8rem; color: var(--muted); margin: 0; }
.vhint code { background: var(--surface); border: 1px solid var(--line); padding: 1px 5px; border-radius: 4px; }
.vout { font-family: var(--font-mono); font-size: .8rem; color: var(--muted); }
.vout[data-state="ok"] { color: var(--run); }
.vout[data-state="bad"] { color: var(--danger); }
footer { margin-top: 24px; font-family: var(--font-mono); font-size: .7rem; color: var(--muted); }
@media (prefers-reduced-motion: no-preference) {
.btn, .chip, .lamp, .card, .notes li, .jline, .layer .seg, .verify input {
transition: background-color .13s ease, border-color .13s ease, color .13s ease, opacity .13s ease, box-shadow .13s ease;
}
}
@media (max-width: 780px) {
.cardbody { grid-template-columns: 1fr; }
.jsonwrap { border-right: 0; border-bottom: 1px solid var(--line); }
.cardhead .sub { margin-left: 0; flex-basis: 100%; }
}
@media (max-width: 620px) {
.notes li { grid-template-columns: minmax(0,1fr); gap: 2px; }
.notes .k { max-width: none; }
}
@media (max-width: 560px) {
.lamps { grid-template-columns: 1fr; }
.wrap { padding: 20px 13px 30px; }
h1 { font-size: 1.28rem; }
.samples { margin-left: 0; }
.layer, .editor textarea { font-size: .78rem; }
.layer { max-height: 220px; }
}
</style>
</head>
<body>
<main class="wrap">
<header>
<h1>Token anatomy</h1>
<p class="lede">A JWT is three chunks of text glued together with dots. This page pulls one apart, decodes it and
explains what every claim means — <b>entirely inside this tab</b>, with no network requests of any kind.</p>
</header>
<section class="panel" aria-labelledby="tokh">
<div class="panelhead">
<h2 id="tokh">The token</h2>
<div class="samples" role="group" aria-label="Sample tokens" id="samples"></div>
</div>
<div class="editor" id="editor">
<pre class="layer" id="layer" aria-hidden="true"></pre>
<label class="sr-only" for="tok">Paste a JSON Web Token</label>
<textarea id="tok" spellcheck="false" autocapitalize="off" autocorrect="off"
placeholder="paste a token here"></textarea>
</div>
<div class="legend" id="legend">
<button type="button" class="chip" data-seg="h"><span class="sw" aria-hidden="true"></span>header <span class="n" id="lenH">—</span></button>
<button type="button" class="chip" data-seg="p"><span class="sw" aria-hidden="true"></span>payload <span class="n" id="lenP">—</span></button>
<button type="button" class="chip" data-seg="s"><span class="sw" aria-hidden="true"></span>signature <span class="n" id="lenS">—</span></button>
<button type="button" class="btn ghost" id="clear">clear</button>
</div>
<p class="privacy"><b>Decoding is not verifying.</b> The first two parts are just text in disguise — anyone
holding a token can read them, and this page did exactly that with plain JavaScript. Reading the claims tells you
what a token <em>says</em>, never that it is genuine. Nothing you type here leaves the page.</p>
</section>
<section class="lamps" aria-label="Token status">
<div class="lamp" id="lampTime" data-state="idle">
<span class="k-lab">time window</span>
<span class="v" id="timeV">no token</span>
<span class="d" id="timeD">Paste a token or pick a sample.</span>
</div>
<div class="lamp" id="lampSig" data-state="idle">
<span class="k-lab">signature</span>
<span class="v" id="sigV">not checked</span>
<span class="d" id="sigD">Decoding never touches the signature.</span>
</div>
</section>
<p class="sr-only" role="status" id="statusMsg"></p>
<div id="flags"></div>
<section class="card" data-seg="h" id="cardH" tabindex="-1" aria-labelledby="hTitle">
<div class="cardhead">
<span class="tag">part 1 of 3</span>
<h2 id="hTitle">Header</h2>
<span class="sub">how the token was signed</span>
</div>
<div class="cardbody">
<div class="jsonwrap">
<div class="jsonbar"><span class="k-lab">decoded json</span><button type="button" class="btn" data-copy="h">copy</button></div>
<pre class="json" id="hJson"><span class="ph">waiting for a token</span></pre>
</div>
<ul class="notes" id="hNotes"></ul>
</div>
</section>
<section class="card" data-seg="p" id="cardP" tabindex="-1" aria-labelledby="pTitle">
<div class="cardhead">
<span class="tag">part 2 of 3</span>
<h2 id="pTitle">Payload</h2>
<span class="sub">what the token claims</span>
</div>
<div class="cardbody">
<div class="jsonwrap">
<div class="jsonbar"><span class="k-lab">decoded json</span><button type="button" class="btn" data-copy="p">copy</button></div>
<pre class="json" id="pJson"><span class="ph">waiting for a token</span></pre>
</div>
<ul class="notes" id="pNotes"></ul>
</div>
</section>
<section class="card" data-seg="s" id="cardS" tabindex="-1" aria-labelledby="sTitle">
<div class="cardhead">
<span class="tag">part 3 of 3</span>
<h2 id="sTitle">Signature</h2>
<span class="sub">the part you cannot read</span>
</div>
<div class="cardbody">
<div class="sigbody">
<div class="sigraw" id="sRaw">—</div>
<div class="sigfacts">
<span class="fact"><span class="k-lab">characters</span><span class="fv" id="sChars">—</span></span>
<span class="fact"><span class="k-lab">raw bytes</span><span class="fv" id="sBytes">—</span></span>
<span class="fact"><span class="k-lab">algorithm</span><span class="fv" id="sAlg">—</span></span>
</div>
<p class="sigtext" id="sText">The signature is a fingerprint of the first two parts. Change one character
anywhere above and it stops matching.</p>
<div class="verify" id="verify" hidden>
<div class="vrow">
<label for="secret">shared secret</label>
<input id="secret" type="text" spellcheck="false" autocapitalize="off" autocorrect="off" placeholder="type the secret to check the signature">
</div>
<p class="vhint" id="vhint">The samples on this page were signed in your browser with <code>rosewood-demo-secret</code>. Try it, then change one letter.</p>
<div class="vout" id="vout" data-state="idle">waiting for a secret</div>
</div>
</div>
</div>
</section>
<footer>demo · sloprun.dev</footer>
</main>
<script>
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
var TE = new TextEncoder();
/* fatal: a base64url part that is not real UTF-8 must be reported as such, not
quietly papered over with U+FFFD replacement characters. */
var TD = new TextDecoder("utf-8", { fatal: true });
/* ================= base64url ================= */
function b64urlToBytes(s) {
var t = s.replace(/-/g, "+").replace(/_/g, "/");
if (/[^A-Za-z0-9+/=]/.test(t)) throw new Error("bad-chars");
var pad = t.length % 4;
if (pad === 1) throw new Error("bad-length");
if (pad) t += new Array(5 - pad).join("=");
var bin = atob(t);
var out = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function bytesToB64url(b) {
var s = "";
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function strToB64url(str) { return bytesToB64url(TE.encode(str)); }
/* ================= SHA-256 + HMAC (pure JS, nothing imported) ================= */
var K256 = [
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
];
function rotr(x, n) { return (x >>> n) | (x << (32 - n)); }
function sha256(bytes) {
var H = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
var l = bytes.length, total = ((l + 9 + 63) >> 6) << 6;
var m = new Uint8Array(total);
m.set(bytes); m[l] = 0x80;
var dv = new DataView(m.buffer);
dv.setUint32(total - 8, Math.floor(l / 536870912) >>> 0);
dv.setUint32(total - 4, (l * 8) >>> 0);
var w = new Uint32Array(64);
var i, t, a, b, c, d, e, f, g, h, s0, s1, ch, maj, T1, T2;
for (i = 0; i < total; i += 64) {
for (t = 0; t < 16; t++) w[t] = dv.getUint32(i + t * 4);
for (t = 16; t < 64; t++) {
s0 = rotr(w[t-15], 7) ^ rotr(w[t-15], 18) ^ (w[t-15] >>> 3);
s1 = rotr(w[t-2], 17) ^ rotr(w[t-2], 19) ^ (w[t-2] >>> 10);
w[t] = (w[t-16] + s0 + w[t-7] + s1) >>> 0;
}
a=H[0]; b=H[1]; c=H[2]; d=H[3]; e=H[4]; f=H[5]; g=H[6]; h=H[7];
for (t = 0; t < 64; t++) {
s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
ch = (e & f) ^ (~e & g);
T1 = (h + s1 + ch + K256[t] + w[t]) >>> 0;
s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
maj = (a & b) ^ (a & c) ^ (b & c);
T2 = (s0 + maj) >>> 0;
h=g; g=f; f=e; e=(d + T1) >>> 0; d=c; c=b; b=a; a=(T1 + T2) >>> 0;
}
H[0]=(H[0]+a)>>>0; H[1]=(H[1]+b)>>>0; H[2]=(H[2]+c)>>>0; H[3]=(H[3]+d)>>>0;
H[4]=(H[4]+e)>>>0; H[5]=(H[5]+f)>>>0; H[6]=(H[6]+g)>>>0; H[7]=(H[7]+h)>>>0;
}
var out = new Uint8Array(32), ov = new DataView(out.buffer);
for (i = 0; i < 8; i++) ov.setUint32(i * 4, H[i]);
return out;
}
function hmacSha256(keyBytes, msgBytes) {
var k = keyBytes.length > 64 ? sha256(keyBytes) : keyBytes;
var ip = new Uint8Array(64 + msgBytes.length), op = new Uint8Array(64 + 32), i, kb;
for (i = 0; i < 64; i++) { kb = i < k.length ? k[i] : 0; ip[i] = kb ^ 0x36; op[i] = kb ^ 0x5c; }
ip.set(msgBytes, 64);
op.set(sha256(ip), 64);
return sha256(op);
}
/* ================= claim dictionary (plain words) ================= */
var TIME = true;
var CLAIMS = {
/* header */
alg: ["Algorithm", "The signing recipe. A server should decide for itself which recipes it accepts — never take this field's word for it."],
typ: ["Type", "Says what kind of token this is. Nearly always JWT."],
kid: ["Key ID", "Which of the issuer's signing keys was used, so the receiver knows which key to check against."],
cty: ["Content type", "Describes what is wrapped inside when the payload is not plain claims."],
enc: ["Encryption", "Appears when the payload is scrambled rather than merely signed."],
crit: ["Must understand", "Header fields the receiver has to understand, or else refuse the token outright."],
jku: ["Key hint", "A place the receiver can look up the public key that matches this signature."],
jwk: ["Inline key", "A public key carried inside the token itself. Trusting it blindly is a classic mistake."],
x5t: ["Certificate thumbprint", "A fingerprint of the certificate holding the signing key."],
/* registered payload */
iss: ["Issuer", "Who minted this token — usually the sign-in service that signed it."],
sub: ["Subject", "Who the token is about. Usually a stable internal ID rather than a name or email."],
aud: ["Audience", "Which app is meant to accept this token. Anything else should turn it away."],
exp: ["Expires at", "After this moment the token should be refused, even if everything else about it checks out.", TIME],
nbf: ["Not before", "The token lies dormant until this moment — handy for passes handed out ahead of time.", TIME],
iat: ["Issued at", "When the token was minted. Apps use it to reject anything suspiciously old.", TIME],
jti: ["Token ID", "A unique id for this one token, so it can be logged or cancelled on its own."],
/* common extras */
scope: ["Scope", "The list of things the holder is allowed to do. Your app still has to enforce every item on it."],
scp: ["Scope", "The list of things the holder is allowed to do, written as a list instead of a string."],
azp: ["Authorized party", "The client app this token was handed to."],
sid: ["Session ID", "Ties the token to one sign-in session, so signing out can retire it."],
auth_time: ["Signed in at", "When the person actually proved who they were, which can be much earlier than this token was made.", TIME],
nonce: ["Nonce", "A one-shot value linking this token back to the exact sign-in request that asked for it."],
at_hash: ["Companion fingerprint", "A short hash of another token issued alongside this one, so the pair can be matched up."],
amr: ["How they signed in", "Which methods were used to sign in, such as a password or a one-time code."],
acr: ["Confidence level", "How sure the issuer is about who this is, on a scale the issuer defines."],
name: ["Display name", "A human-friendly name for the account. Convenient for the interface, never a security check."],
given_name: ["First name", "Part of the standard profile a sign-in service can share."],
family_name: ["Last name", "Part of the standard profile a sign-in service can share."],
preferred_username: ["Username", "The handle this person likes to be shown as."],
nickname: ["Nickname", "A casual name for the account."],
email: ["Email", "The address on the account. Only meaningful if the next claim says it was checked."],
email_verified: ["Email checked", "Whether the issuer confirmed this address really belongs to the account."],
phone_number: ["Phone", "The phone number on the account."],
picture: ["Avatar", "A link to the account's picture. The image is not embedded — just the address."],
locale: ["Language", "The language and region the person prefers."],
zoneinfo: ["Time zone", "The time zone the person lives in."],
updated_at: ["Profile updated", "When the profile behind these claims last changed.", TIME],
role: ["Role", "A single label your app can check before showing something."],
roles: ["Roles", "Labels your app can check before showing something. The token only claims them; your app decides what they unlock."],
groups: ["Groups", "Which groups the account belongs to, for the app to check."],
permissions: ["Permissions", "Specific actions the holder is allowed to take."],
client_id: ["Client app", "Which application asked for this token."],
ver: ["Version", "Which version of the issuer's token format this is."]
};
function claimInfo(k) {
if (Object.prototype.hasOwnProperty.call(CLAIMS, k)) return CLAIMS[k];
return ["Custom claim", "Not one of the standard names — whatever the app that issued this decided to tuck in."];
}
var ALGS = {
HS256: "HMAC with SHA-256 over a secret both sides already share.",
HS384: "HMAC with SHA-384 over a secret both sides already share.",
HS512: "HMAC with SHA-512 over a secret both sides already share.",
RS256: "An RSA signature, checked with the issuer's public key. Only the issuer can make one.",
RS512: "An RSA signature with SHA-512, checked with the issuer's public key.",
PS256: "An RSA-PSS signature, checked with the issuer's public key.",
ES256: "An elliptic-curve signature, checked with the issuer's public key. Shorter than RSA, same idea.",
ES384: "An elliptic-curve signature with SHA-384.",
EdDSA: "An Edwards-curve signature, checked with the issuer's public key.",
none: "No signature at all. The token is plain text anyone can rewrite."
};
/* ================= helpers ================= */
function esc(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
function escAttr(s) { return esc(s).replace(/"/g, """); }
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function fmtAbs(sec) {
var d = new Date(sec * 1000);
if (isNaN(d.getTime())) return "unreadable date";
return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " " +
pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds());
}
function dur(secAbs) {
var s = Math.floor(Math.abs(secAbs));
if (s < 60) return s + " s";
if (s < 3600) return Math.floor(s / 60) + " min " + pad2(s % 60) + " s";
if (s < 86400) return Math.floor(s / 3600) + " h " + pad2(Math.floor((s % 3600) / 60)) + " min";
return Math.floor(s / 86400) + " d " + Math.floor((s % 86400) / 3600) + " h";
}
function rel(sec, now) {
var d = sec - now;
return d >= 0 ? "in " + dur(d) : dur(d) + " ago";
}
function isTimeVal(v) { return typeof v === "number" && isFinite(v) && v > 100000000 && v < 4102444800; }
/* ================= JSON pretty printer ================= */
function colorize(text) {
return esc(text).replace(
/"(?:\\.|[^"\\])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g,
function (m, colon) {
if (colon) return '<span class="jk">' + m.slice(0, m.length - colon.length) + "</span>" + colon;
if (m.charAt(0) === '"') return '<span class="js">' + m + "</span>";
return '<span class="jn">' + m + "</span>";
}
);
}
function renderJson(obj) {
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
return colorize(JSON.stringify(obj, null, 2));
}
var keys = Object.keys(obj);
if (!keys.length) return '<span class="jbr">{ }</span>';
var out = '<span class="jbr">{</span>';
keys.forEach(function (k, i) {
var val = colorize(JSON.stringify(obj[k], null, 2).split("\n").join("\n "));
out += '<span class="jline" data-k="' + escAttr(k) + '"> <span class="jk">"' + esc(k) + '"</span>' +
'<span class="jp">: </span>' + val + (i < keys.length - 1 ? '<span class="jp">,</span>' : "") + "</span>";
});
return out + '<span class="jbr">}</span>';
}
/* ================= samples, built and signed right here ================= */
var SECRET = "rosewood-demo-secret";
var ISS = "https://accounts.rosewood-roasters.example";
function makeToken(header, payload, secret) {
var h = strToB64url(JSON.stringify(header));
var p = strToB64url(JSON.stringify(payload));
if (header.alg === "none") return h + "." + p + ".";
return h + "." + p + "." + bytesToB64url(hmacSha256(TE.encode(secret), TE.encode(h + "." + p)));
}
var SAMPLES = [];
function buildSamples() {
var now = Math.floor(Date.now() / 1000);
SAMPLES = [
{ id: "live", label: "signed in", token: makeToken(
{ alg: "HS256", typ: "JWT", kid: "roast-2026-03" },
{ iss: ISS, sub: "usr_8f24c1b0", aud: "rosewood-web", name: "Maya Okonjo",
email: "[email protected]", email_verified: true, role: "barista",
scope: "orders:read orders:write profile", sid: "sess_2f9a41",
jti: "tok_5c1d9e77", iat: now - 120, nbf: now - 120, exp: now + 900 }, SECRET) },
{ id: "soon", label: "about to expire", token: makeToken(
{ alg: "HS256", typ: "JWT", kid: "roast-2026-03" },
{ iss: ISS, sub: "usr_8f24c1b0", aud: "rosewood-web", name: "Maya Okonjo",
email: "[email protected]", email_verified: true, role: "barista",
scope: "orders:read profile", sid: "sess_2f9a41", jti: "tok_b3e70c19",
iat: now - 855, nbf: now - 855, exp: now + 45 }, SECRET) },
{ id: "expired", label: "expired", token: makeToken(
{ alg: "HS256", typ: "JWT", kid: "roast-2026-03" },
{ iss: ISS, sub: "usr_8f24c1b0", aud: "rosewood-web", name: "Maya Okonjo",
email: "[email protected]", email_verified: true, role: "barista",
scope: "orders:read profile", jti: "tok_a09e31bb",
iat: now - 10800, nbf: now - 10800, exp: now - 7245 }, SECRET) },
{ id: "early", label: "not yet valid", token: makeToken(
{ alg: "HS256", typ: "JWT", kid: "roast-2026-03" },
{ iss: ISS, sub: "usr_c7b31d04", aud: "rosewood-kiosk", name: "Dev Patel",
role: "supplier", scope: "deliveries:write", jti: "tok_77b2ce10",
iat: now - 45, nbf: now + 620, exp: now + 4200 }, SECRET) },
{ id: "packed", label: "packed with claims", token: makeToken(
{ alg: "HS256", typ: "JWT", kid: "roast-2026-03" },
{ iss: ISS, sub: "usr_41d0a7e2", aud: ["rosewood-web", "rosewood-kiosk"],
azp: "rosewood-kiosk", name: "Sofía Ríos", preferred_username: "sofia.rios",
email: "[email protected]", email_verified: true,
picture: "https://example.com/avatars/sofia.png",
locale: "es-MX", zoneinfo: "America/Mexico_City",
amr: ["pwd", "otp"], acr: "medium", auth_time: now - 1805,
scope: "orders:read profile", loyalty_tier: "gold", loyalty_points: 1240,
favourite_drink: "cortado", table_number: 12,
sid: "sess_be0117", jti: "tok_31f9d2a6", iat: now - 30, nbf: now - 30, exp: now + 3600 }, SECRET) },
{ id: "none", label: "alg: none", token: makeToken(
{ alg: "none", typ: "JWT" },
{ iss: ISS, sub: "usr_8f24c1b0", name: "Maya Okonjo", role: "barista",
scope: "orders:read profile",
jti: "tok_0000dead", iat: now - 60, exp: now + 900 }, "") }
];
}
/* ================= elements ================= */
var ta = $("tok"), layer = $("layer"), editor = $("editor");
var lampTime = $("lampTime"), lampSig = $("lampSig");
var timeV = $("timeV"), timeD = $("timeD"), sigV = $("sigV"), sigD = $("sigD");
var flagsEl = $("flags"), statusMsg = $("statusMsg");
var hJson = $("hJson"), pJson = $("pJson"), hNotes = $("hNotes"), pNotes = $("pNotes");
var sRaw = $("sRaw"), sChars = $("sChars"), sBytes = $("sBytes"), sAlg = $("sAlg"), sText = $("sText");
var verify = $("verify"), secretIn = $("secret"), vout = $("vout");
var lenH = $("lenH"), lenP = $("lenP"), lenS = $("lenS");
var sTextIdle = sText.innerHTML; /* kept so clearing restores it instead of stranding the last token's algorithm blurb */
/* ================= token text layer ================= */
function layerHtml(raw) {
if (!raw) return "";
var mm = /^(\s*(?:Bearer\s+)?)/i.exec(raw);
var pre = mm ? mm[1] : "";
var body = raw.slice(pre.length);
var html = pre ? '<span class="pre">' + esc(pre) + "</span>" : "";
var parts = body.split(".");
var cls = ["seg-h", "seg-p", "seg-s"];
for (var i = 0; i < parts.length; i++) {
if (i) html += '<span class="dot">.</span>';
if (parts[i] !== "") {
html += '<span class="seg ' + (i < 3 ? cls[i] : "seg-x") + '">' + esc(parts[i]) + "</span>";
}
}
return html;
}
function syncScroll() { layer.scrollTop = ta.scrollTop; layer.scrollLeft = ta.scrollLeft; }
/* ================= state ================= */
var state = { header: null, payload: null, sig: "", signing: "", alg: "", parts: 0 };
var said = { time: ["", ""], sig: ["", ""] };
function setLamp(el, vEl, dEl, st, v, d) {
el.setAttribute("data-state", st);
vEl.textContent = v;
dEl.innerHTML = d;
}
function announce(chan, key, text) {
if (said[chan][0] === key) return;
said[chan] = [key, text];
statusMsg.textContent = said.time[1] + " " + said.sig[1];
}
function flag(kind, tag, html) {
var d = document.createElement("div");
d.className = "flag";
d.setAttribute("data-kind", kind);
d.innerHTML = '<span class="t">' + esc(tag) + "</span><span>" + html + "</span>";
flagsEl.appendChild(d);
}
function decodePart(s) {
var bytes;
try { bytes = b64urlToBytes(s); }
catch (e) { return { ok: false, why: "b64" }; }
var text;
try { text = TD.decode(bytes); }
catch (e) { return { ok: false, why: "utf8" }; }
var obj;
try { obj = JSON.parse(text); }
catch (e) { return { ok: false, why: "json", text: text }; }
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return { ok: false, why: "shape", text: text };
return { ok: true, obj: obj, bytes: bytes };
}
function noteRow(key, value, now) {
var info = claimInfo(key);
var li = document.createElement("li");
li.setAttribute("data-k", key);
var vHtml;
if (info[2] === TIME && isTimeVal(value)) {
var cls = "";
if (key === "exp") cls = value <= now ? "cold" : "hot";
if (key === "nbf" && value > now) cls = "hot";
vHtml = fmtAbs(value) + ' <span class="jp">local ·</span> <span class="' + cls +
'" data-tick="' + escAttr(key) + '" data-at="' + value + '">' + rel(value, now) + "</span>";
} else if (typeof value === "string") {
vHtml = esc(value.length > 220 ? value.slice(0, 220) + "…" : value);
} else {
var j = JSON.stringify(value);
vHtml = esc(j && j.length > 220 ? j.slice(0, 220) + "…" : j);
}
li.innerHTML = '<code class="k">' + esc(key) + "</code>" +
'<span class="b"><span class="lab">' + info[0] + "</span>" +
'<span class="t">' + info[1] + "</span>" +
'<span class="v">' + vHtml + "</span></span>";
return li;
}
function fillNotes(ul, obj, now) {
ul.innerHTML = "";
Object.keys(obj).forEach(function (k) { ul.appendChild(noteRow(k, obj[k], now)); });
if (!Object.keys(obj).length) {
var li = document.createElement("li");
li.className = "empty";
li.innerHTML = '<span class="b"><span class="t">This part decoded to an empty object — no claims at all.</span></span>';
ul.appendChild(li);
}
}
function clearOut(msg) {
hJson.innerHTML = '<span class="ph">' + msg + "</span>";
pJson.innerHTML = '<span class="ph">' + msg + "</span>";
hNotes.innerHTML = ""; pNotes.innerHTML = "";
sRaw.textContent = "—"; sChars.textContent = "—"; sBytes.textContent = "—"; sAlg.textContent = "—";
sText.innerHTML = sTextIdle;
lenH.textContent = "—"; lenP.textContent = "—"; lenS.textContent = "—";
verify.hidden = true;
vout.setAttribute("data-state", "idle");
vout.textContent = "waiting for a secret";
state = { header: null, payload: null, sig: "", signing: "", alg: "", parts: 0 };
}
/* ================= main render ================= */
function render() {
var raw = ta.value;
layer.innerHTML = layerHtml(raw);
syncScroll();
flagsEl.innerHTML = "";
var clean = raw.trim();
var hadBearer = /^Bearer\s+/i.test(clean);
if (hadBearer) clean = clean.replace(/^Bearer\s+/i, "");
var hadSpace = /\s/.test(clean);
clean = clean.replace(/\s+/g, "");
if (!clean) {
clearOut("waiting for a token");
setLamp(lampTime, timeV, timeD, "idle", "no token", "Paste a token or pick a sample.");
setLamp(lampSig, sigV, sigD, "idle", "not checked", "Decoding never touches the signature.");
said.sig = ["empty", "Signature not checked."];
announce("time", "empty", "No token loaded.");
return;
}
if (hadBearer) flag("info", "tidied", "Dropped the leading <code>Bearer</code> — that word belongs to the HTTP header, not to the token.");
if (hadSpace) flag("info", "tidied", "Removed line breaks and spaces before decoding.");
var parts = clean.split(".");
state.parts = parts.length;
if (parts.length !== 3) {
flag("bad", "shape", "A JWT is three parts joined by dots — <code>header.payload.signature</code>. This text has <b>" +
parts.length + "</b>.");
}
var now = Math.floor(Date.now() / 1000);
var hRes = parts.length > 0 ? decodePart(parts[0]) : { ok: false, why: "missing" };
var pRes = parts.length > 1 ? decodePart(parts[1]) : { ok: false, why: "missing" };
var sig = parts.length > 2 ? parts[2] : "";
lenH.textContent = (parts[0] || "").length + " ch";
lenP.textContent = (parts[1] || "").length + " ch";
lenS.textContent = sig.length + " ch";
/* ---- header ---- */
if (hRes.ok) {
state.header = hRes.obj;
state.alg = typeof hRes.obj.alg === "string" ? hRes.obj.alg : "";
hJson.innerHTML = renderJson(hRes.obj);
fillNotes(hNotes, hRes.obj, now);
} else {
state.header = null; state.alg = "";
hJson.innerHTML = '<span class="ph">could not decode this part</span>';
hNotes.innerHTML = "";
flag("bad", "part 1", partProblem(hRes, "header"));
}
/* ---- payload ---- */
if (pRes.ok) {
state.payload = pRes.obj;
pJson.innerHTML = renderJson(pRes.obj);
fillNotes(pNotes, pRes.obj, now);
} else {
state.payload = null;
pJson.innerHTML = '<span class="ph">could not decode this part</span>';
pNotes.innerHTML = "";
flag("bad", "part 2", partProblem(pRes, "payload"));
}
/* ---- signature ---- */
state.sig = sig;
state.signing = (parts[0] || "") + "." + (parts[1] || "");
var algName = state.alg || "not stated";
sAlg.textContent = algName;
sRaw.textContent = sig || "(empty — this token carries no signature)";
sChars.textContent = sig.length;
var sigBytes = -1;
try { sigBytes = b64urlToBytes(sig).length; } catch (e) { sigBytes = -1; }
sBytes.textContent = sigBytes >= 0 ? sigBytes : "unreadable";
var algText = ALGS[state.alg] || "An algorithm this page does not have a description for.";
sText.innerHTML = "<b>" + esc(algName) + "</b> — " + algText +
(state.alg === "none" ? "" :
" The signature covers the first two parts exactly as they are written above, so changing a single character anywhere in them breaks it.");
if (state.alg === "none") {
var promote = !state.payload || state.payload.role !== "owner";
flag("bad", "no signature", "The header says <code>\"alg\": \"none\"</code>. There is nothing here to check — " +
"any server that accepts this token is trusting text that anyone can rewrite." +
(state.payload ? " Try it: <button type=\"button\" class=\"btn\" id=\"tamperBtn\">" +
(promote ? "promote yourself to owner" : "put the role back") +
"</button> rewrites a claim, re-encodes the token, and it still decodes perfectly." : ""));
var tb = document.getElementById("tamperBtn");
if (tb) tb.addEventListener("click", tamper);
} else if (!sig && parts.length === 3) {
flag("bad", "no signature", "The signature slot is empty, but the header claims a real algorithm. Nothing can be checked.");
}
verify.hidden = !(state.alg === "HS256" && parts.length === 3 && sig);
updateTimeLamp(now);
updateSigLamp();
tick();
}
/* Rewrite one claim and re-encode. Only reachable for alg:none, where no secret
stands in the way — which is exactly the point being made. */
function tamper() {
if (!state.payload || !state.header) return;
var p = {}, k;
for (k in state.payload) if (Object.prototype.hasOwnProperty.call(state.payload, k)) p[k] = state.payload[k];
if (p.role === "owner") { p.role = "barista"; p.scope = "orders:read profile"; }
else { p.role = "owner"; p.scope = "orders:write inventory:write staff:manage"; }
ta.value = strToB64url(JSON.stringify(state.header)) + "." + strToB64url(JSON.stringify(p)) + ".";
markSample("");
render();
var again = document.getElementById("tamperBtn");
if (again) again.focus({ preventScroll: true });
}
function partProblem(res, name) {
if (res.why === "b64") return "The " + name + " part is not valid base64url text, so there is nothing to decode.";
if (res.why === "utf8") return "The " + name + " part decoded to bytes that are not readable text.";
if (res.why === "json") return "The " + name + " part decoded to text, but that text is not valid JSON.";
if (res.why === "shape") return "The " + name + " part is valid JSON, but a JWT " + name + " has to be an object with named claims.";
return "There is no " + name + " part in this text.";
}
/* ---- time lamp ---- */
function updateTimeLamp(now) {
var p = state.payload;
if (!p) {
setLamp(lampTime, timeV, timeD, "idle", "unknown", "No readable payload, so there is nothing to date.");
announce("time", "none", "Time window unknown.");
return;
}
var exp = isTimeVal(p.exp) ? p.exp : null;
var nbf = isTimeVal(p.nbf) ? p.nbf : null;
if (exp !== null && exp <= now) {
setLamp(lampTime, timeV, timeD, "bad", "EXPIRED",
'Ran out <span class="num" data-tick="lampexp" data-at="' + exp + '">' + rel(exp, now) + "</span> — a careful server refuses it now.");
announce("time", "exp", "This token has expired.");
return;
}
if (nbf !== null && nbf > now) {
setLamp(lampTime, timeV, timeD, "warn", "NOT YET VALID",
'Wakes up <span class="num" data-tick="lampnbf" data-at="' + nbf + '">' + rel(nbf, now) + "</span>.");
announce("time", "nbf", "This token is not valid yet.");
return;
}
if (exp === null) {
setLamp(lampTime, timeV, timeD, "warn", "NO EXPIRY",
"This token has no <code>exp</code> claim, so it never runs out on its own. That is a long time to trust one string.");
announce("time", "noexp", "This token has no expiry.");
return;
}
setLamp(lampTime, timeV, timeD, "ok", "IN WINDOW",
'Expires <span class="num" data-tick="lampexp" data-at="' + exp + '">' + rel(exp, now) + "</span>.");
announce("time", "ok", "This token is inside its valid window.");
}
/* ---- signature lamp + verification ---- */
function verifyNow() {
if (state.alg === "none") return { st: "bad", v: "NONE", d: "The token says it has no signature, so there is nothing to check." };
if (!state.sig) return { st: "bad", v: "MISSING", d: "The signature slot is empty." };
if (!state.alg) {
return { st: "idle", v: "NOT CHECKED", d: "The header never says which algorithm was used, so there is nothing to check this signature against." };
}
if (state.alg !== "HS256") {
return { st: "idle", v: "NOT CHECKED", d: "This page can only re-compute <code>HS256</code> signatures, because those use a shared secret you can type in. <b>" +
esc(state.alg) + "</b> is checked with the issuer's public key — a decoder has no way to invent one." };
}
var secret = secretIn.value;
if (!secret) return { st: "idle", v: "NOT CHECKED", d: "Decoding read the claims without ever touching the signature. Type the shared secret below to actually check it." };
var want;
try { want = bytesToB64url(hmacSha256(TE.encode(secret), TE.encode(state.signing))); }
catch (e) { return { st: "idle", v: "NOT CHECKED", d: "Could not run the check on this input." }; }
if (want === state.sig) return { st: "ok", v: "MATCHES", d: "Re-signed the first two parts with that secret and got the same signature. This token has not been edited." };
return { st: "bad", v: "NO MATCH", d: "Re-signing with that secret gives a different signature. Either the secret is wrong or the token was changed." };
}
function updateSigLamp() {
var r = verifyNow();
setLamp(lampSig, sigV, sigD, r.st, r.v, r.d);
if (!verify.hidden) {
vout.setAttribute("data-state", r.st === "ok" ? "ok" : (r.st === "bad" ? "bad" : "idle"));
vout.textContent = !secretIn.value ? "waiting for a secret"
: (r.st === "ok" ? "signature matches" : (r.st === "bad" ? "signature does not match" : "not checked"));
}
announce("sig", r.st + "|" + r.v, "Signature: " + r.v.toLowerCase() + ".");
}
/* ---- one-second tick for countdowns ---- */
function tick() {
var now = Math.floor(Date.now() / 1000);
var nodes = document.querySelectorAll("[data-tick]");
for (var i = 0; i < nodes.length; i++) {
var at = parseFloat(nodes[i].getAttribute("data-at"));
if (!isFinite(at)) continue;
nodes[i].textContent = rel(at, now);
var k = nodes[i].getAttribute("data-tick");
if (k === "exp") nodes[i].className = at <= now ? "cold" : "hot";
if (k === "nbf") nodes[i].className = at > now ? "hot" : "";
}
if (state.payload) {
var p = state.payload;
var exp = isTimeVal(p.exp) ? p.exp : null;
var nbf = isTimeVal(p.nbf) ? p.nbf : null;
var st = lampTime.getAttribute("data-state");
var flipped = (exp !== null && exp <= now && st !== "bad") || (nbf !== null && nbf <= now && st === "warn");
if (flipped) updateTimeLamp(now);
}
}
/* ================= interactions ================= */
function setHl(seg) {
if (seg) document.body.setAttribute("data-hl", seg);
else document.body.removeAttribute("data-hl");
}
["h", "p", "s"].forEach(function (seg) {
var card = $("card" + seg.toUpperCase());
card.addEventListener("mouseenter", function () { setHl(seg); });
card.addEventListener("mouseleave", function () { setHl(null); });
card.addEventListener("focusin", function () { setHl(seg); });
card.addEventListener("focusout", function () { setHl(null); });
});
Array.prototype.forEach.call(document.querySelectorAll(".chip"), function (chip) {
var seg = chip.getAttribute("data-seg");
chip.addEventListener("mouseenter", function () { setHl(seg); });
chip.addEventListener("mouseleave", function () { setHl(null); });
chip.addEventListener("focus", function () { setHl(seg); });
chip.addEventListener("blur", function () { setHl(null); });
chip.addEventListener("click", function () {
var card = $("card" + seg.toUpperCase());
card.focus({ preventScroll: true });
card.scrollIntoView({ block: "center", behavior: prefersMotion() ? "smooth" : "auto" });
});
});
function prefersMotion() {
return !(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}
/* hover-link between a JSON line and its explanation */
function linkHover(jsonEl, notesEl) {
function paint(k, on) {
var a = jsonEl.querySelector('.jline[data-k="' + (window.CSS && CSS.escape ? CSS.escape(k) : k) + '"]');
var b = notesEl.querySelector('li[data-k="' + (window.CSS && CSS.escape ? CSS.escape(k) : k) + '"]');
if (a) a.classList.toggle("lit", on);
if (b) b.classList.toggle("lit", on);
}
function handler(e) {
var t = e.target.closest ? e.target.closest("[data-k]") : null;
if (!t) return;
var k = t.getAttribute("data-k");
paint(k, e.type === "mouseover");
}
[jsonEl, notesEl].forEach(function (el) {
el.addEventListener("mouseover", handler);
el.addEventListener("mouseout", handler);
});
}
linkHover(hJson, hNotes);
linkHover(pJson, pNotes);
/* copy buttons */
function copyText(text, btn) {
var done = false;
var finish = function (ok) {
if (done) return;
done = true;
btn.textContent = ok ? "copied" : "copy failed";
btn.classList.toggle("won", ok);
/* the press came from this button - keep the caret here, never steal it away */
if (document.activeElement !== btn) btn.focus({ preventScroll: true });
window.setTimeout(function () { btn.textContent = "copy"; btn.classList.remove("won"); }, 1400);
};
/* legacy path: a throwaway textarea, used only if the async clipboard is unavailable */
var legacy = function () {
var ok = false;
try {
var sel = document.getSelection();
var saved = sel && sel.rangeCount ? sel.getRangeAt(0) : null;
var tmp = document.createElement("textarea");
tmp.value = text;
tmp.setAttribute("readonly", "");
tmp.setAttribute("aria-hidden", "true");
tmp.style.position = "fixed";
tmp.style.top = "-2000px";
document.body.appendChild(tmp);
tmp.select();
tmp.setSelectionRange(0, text.length);
try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
document.body.removeChild(tmp);
if (sel) { sel.removeAllRanges(); if (saved) sel.addRange(saved); }
} catch (e) { ok = false; }
finish(ok);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
navigator.clipboard.writeText(text).then(function () { finish(true); }, legacy);
return;
} catch (e) { /* fall through to the legacy path */ }
}
legacy();
}
Array.prototype.forEach.call(document.querySelectorAll("[data-copy]"), function (btn) {
btn.addEventListener("click", function () {
var which = btn.getAttribute("data-copy");
var obj = which === "h" ? state.header : state.payload;
if (!obj) { btn.textContent = "nothing yet"; window.setTimeout(function () { btn.textContent = "copy"; }, 1200); return; }
copyText(JSON.stringify(obj, null, 2), btn);
});
});
/* samples */
function loadSample(id) {
buildSamples(); /* re-mint so "in 45 s" is 45 s from *now*, not from page load */
var s = null;
for (var i = 0; i < SAMPLES.length; i++) if (SAMPLES[i].id === id) s = SAMPLES[i];
if (!s) return;
ta.value = s.token;
markSample(id);
render();
}
function markSample(id) {
Array.prototype.forEach.call(document.querySelectorAll("#samples .btn"), function (b) {
b.setAttribute("aria-pressed", b.getAttribute("data-id") === id ? "true" : "false");
});
}
function buildSampleButtons() {
var box = $("samples");
box.innerHTML = "";
SAMPLES.forEach(function (s) {
var b = document.createElement("button");
b.type = "button";
b.className = "btn";
b.setAttribute("data-id", s.id);
b.setAttribute("aria-pressed", "false");
b.textContent = s.label;
b.addEventListener("click", function () { loadSample(s.id); });
box.appendChild(b);
});
}
ta.addEventListener("input", function () { markSample(""); render(); });
ta.addEventListener("scroll", syncScroll);
secretIn.addEventListener("input", updateSigLamp);
$("clear").addEventListener("click", function () {
ta.value = ""; markSample(""); render(); ta.focus();
});
/* platform theme */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
/* boot */
buildSamples();
buildSampleButtons();
loadSample("live");
window.setInterval(tick, 1000);
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.