$sloprun.dev

Token Anatomy

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 7 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

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.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/jwt-decoder" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Token Anatomy — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/jwt-decoder

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1173 lines 56.6 KB index.html
demos/jwt-decoder/index.html
<!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 &mdash; <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">&mdash;</span></button>
      <button type="button" class="chip" data-seg="p"><span class="sw" aria-hidden="true"></span>payload <span class="n" id="lenP">&mdash;</span></button>
      <button type="button" class="chip" data-seg="s"><span class="sw" aria-hidden="true"></span>signature <span class="n" id="lenS">&mdash;</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 &mdash; 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">&mdash;</div>
        <div class="sigfacts">
          <span class="fact"><span class="k-lab">characters</span><span class="fv" id="sChars">&mdash;</span></span>
          <span class="fact"><span class="k-lab">raw bytes</span><span class="fv" id="sBytes">&mdash;</span></span>
          <span class="fact"><span class="k-lab">algorithm</span><span class="fv" id="sAlg">&mdash;</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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
  function escAttr(s) { return esc(s).replace(/"/g, "&quot;"); }
  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 &middot;</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 &mdash; 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> &mdash; 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 &mdash; <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> &mdash; " + 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 &mdash; " +
        "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 &mdash; 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 &mdash; 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 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong