[{"data":1,"prerenderedAt":42},["ShallowReactive",2],{"$f5OvDOBc3M5lBwEj5wZjGjmK3FvtgM_PMVMGzxIn_eHI":3},{"slug":4,"title":5,"description":6,"date":7,"tags":8,"tools":13,"draft":16,"html":17,"toc":18,"minutes":41},"verifying-jwt-signatures-in-the-browser","Verifying JWT Signatures in the Browser With Web Crypto","Why decoding a JWT proves nothing, and how ToolOnTap's JWT decoder checks HS, RS, PS and ES signatures with the browser's own crypto, keys and all.","2026-10-12",[9,10,11,12],"jwt","web crypto","security","typescript",[14,15],"jwt-decoder","base64-encode-decode",false,"\u003Cp>An API returns 401, and the only clue is a long string starting with \u003Ccode>eyJ\u003C\u002Fcode>. Pasting it into a decoder shows a payload that looks fine, and that’s where most people stop. But a decoded token only tells you what it claims. It doesn’t tell you whether anyone you trust signed it. When I built ToolOnTap’s \u003Ca href=\"\u002Fjwt-decoder\">JWT decoder\u003C\u002Fa>, I wanted it to answer both questions, using only what the browser already ships: \u003Ccode>atob\u003C\u002Fcode>, \u003Ccode>TextDecoder\u003C\u002Fcode> and Web Crypto.\u003C\u002Fp>\n\u003Ch2 id=\"three-parts-two-of-them-readable-by-anyone\">Three parts, two of them readable by anyone\u003C\u002Fh2>\n\u003Cp>A JSON Web Token (RFC 7519) is three Base64URL strings joined by dots: \u003Ccode>header.payload.signature\u003C\u002Fcode>. The header says which algorithm signed it, the payload holds the claims, and the signature covers the exact text \u003Ccode>header.payload\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp>Base64URL is ordinary Base64 with \u003Ccode>-\u003C\u002Fcode> and \u003Ccode>_\u003C\u002Fcode> in place of \u003Ccode>+\u003C\u002Fcode> and \u003Ccode>\u002F\u003C\u002Fcode>, and with the \u003Ccode>=\u003C\u002Fcode> padding dropped, so a token can sit in a URL or a header without escaping. That’s all the “encoding” there is. The header \u003Ccode>{&quot;alg&quot;:&quot;HS256&quot;,&quot;typ&quot;:&quot;JWT&quot;}\u003C\u002Fcode> always becomes \u003Ccode>eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\u003C\u002Fcode>, which is why so many tokens start with the same characters.\u003C\u002Fp>\n\u003Cp>The decoder does a few things before it trusts the input:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>It strips what people paste along with the token: a “Bearer” prefix, quotes, line breaks.\u003C\u002Fli>\n\u003Cli>It counts the parts. Five parts means an encrypted token (JWE), whose content can’t be read without the decryption key, and the tool says so instead of showing nonsense.\u003C\u002Fli>\n\u003Cli>It decodes the bytes with \u003Ccode>TextDecoder\u003C\u002Fcode> in fatal mode, so broken UTF-8 is an error rather than replacement characters.\u003C\u002Fli>\n\u003Cli>It parses the JSON with the site’s own parser first, which keeps large numbers exact. \u003Ccode>JSON.parse\u003C\u002Fcode> would round a 64-bit user ID above 2^53 without telling you.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2 id=\"the-claims-that-decide-whether-a-token-works\">The claims that decide whether a token works\u003C\u002Fh2>\n\u003Cp>Most 401s come down to three registered claims, all of them Unix times in \u003Cstrong>seconds\u003C\u002Fstrong>:\u003C\u002Fp>\n\u003Cdiv class=\"table-wrap\">\u003Ctable>\u003Cthead>\n\u003Ctr>\n\u003Cth>Claim\u003C\u002Fth>\n\u003Cth>Meaning\u003C\u002Fth>\n\u003Cth>Check in the tool\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>exp\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Expires at\u003C\u002Ftd>\n\u003Ctd>“Valid for another …” or “Expired … ago”\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>nbf\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Not valid before\u003C\u002Ftd>\n\u003Ctd>“Not valid for another …”\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>iat\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Issued at\u003C\u002Ftd>\n\u003Ctd>Shown as a date, local time and UTC\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\u003C\u002Fdiv>\u003Cp>The countdown updates every 15 seconds while the page is open. \u003Ccode>nbf\u003C\u002Fcode> is checked before \u003Ccode>exp\u003C\u002Fcode>, so a token from a server whose clock runs ahead shows up as “not valid yet” instead of looking fine.\u003C\u002Fp>\n\u003Cp>One mistake comes up often enough that the decoder looks for it. JavaScript’s \u003Ccode>Date.now()\u003C\u002Fcode> returns milliseconds, and putting that straight into \u003Ccode>exp\u003C\u002Fcode> gives a date tens of thousands of years away. The check is one line:\u003C\u002Fp>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">if\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">typeof\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> exp \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">===\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> 'number'\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> &#x26;&#x26;\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> exp \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">>\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> 1e11\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">) warnings.\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">push\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">'“exp” looks like milliseconds. JWT times are in seconds, so most libraries will read this as a date thousands of years away.'\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">)\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>A time in seconds won’t pass 1e11 until about the year 5138, so the warning doesn’t fire on real expiry dates.\u003C\u002Fp>\n\u003Cp>The decoder also warns when \u003Ccode>alg\u003C\u002Fcode> is \u003Ccode>none\u003C\u002Fcode> or missing. An unsigned token is just JSON that anyone could have typed.\u003C\u002Fp>\n\u003Ch2 id=\"decoding-isn-t-verifying\">Decoding isn’t verifying\u003C\u002Fh2>\n\u003Cp>This is the point I most wanted the page to make. Anyone can change \u003Ccode>&quot;role&quot;: &quot;editor&quot;\u003C\u002Fcode> to \u003Ccode>&quot;role&quot;: &quot;admin&quot;\u003C\u002Fcode>, Base64URL-encode it again and paste the result. It decodes perfectly. Only the signature check notices, and the tests include exactly that case: a valid HS256 token with its payload swapped for \u003Ccode>{ admin: true }\u003C\u002Fcode> must come back as invalid.\u003C\u002Fp>\n\u003Cp>Verification needs the right key, and the kind of key depends on the algorithm. The tool reads the algorithm from the header and maps it to Web Crypto’s names:\u003C\u002Fp>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">export\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> function\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> algorithm\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#b35309;--shiki-dark:#FFAB70\">alg\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">:\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> string\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">)\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">:\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> Algo\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> |\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> null\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">  const\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> m\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \u002F\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">^\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#DBEDFF\">(HS\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">|\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#DBEDFF\">RS\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">|\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#DBEDFF\">PS\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">|\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#DBEDFF\">ES)(256\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">|\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#DBEDFF\">384\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">|\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#DBEDFF\">512)\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">$\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\u002F\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">.\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">exec\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(alg)\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">  if\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">!\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">m) \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">return\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> null\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">  const\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> hash\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> `SHA-${\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">m\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">[\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">2\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">]\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">}`\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> as\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> Algo\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">[\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">'hash'\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">]\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">  const\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> kind\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> ({ HS: \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">'hmac'\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">, RS: \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">'rsa'\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">, PS: \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">'pss'\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">, ES: \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">'ec'\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> } \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">as\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> const\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">)[m[\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">1\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">] \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">as\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> 'HS'\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> |\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> 'RS'\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> |\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> 'PS'\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> |\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> 'ES'\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">]\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">  return\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> { kind, hash, curve: kind \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">===\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> 'ec'\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> ?\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> `P-${\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">m\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">[\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">2\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">] \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">===\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> '512'\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> ?\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> '521'\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> :\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> m\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">[\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">2\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">]\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">}`\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\"> :\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> undefined\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">, saltLength: \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">Number\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(m[\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">2\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">]) \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#cb2431;--shiki-dark:#F97583\">\u002F\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> 8\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> }\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Two details in there are easy to get wrong:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>ES512 uses the P-521 curve\u003C\u002Fstrong>, not a “P-512”. The 512 is the hash; the curve is a different number. Ask Web Crypto for P-512 and the key import simply fails.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>PS algorithms use a salt as long as the hash\u003C\u002Fstrong>: 32 bytes for PS256, 48 for PS384, 64 for PS512. That’s what RFC 7518 specifies, and Web Crypto needs the number passed explicitly.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2 id=\"keys-secrets-pem-and-jwk\">Keys: secrets, PEM and JWK\u003C\u002Fh2>\n\u003Cp>The tool accepts three kinds of key, and it tells them apart by looking at what you pasted.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>A shared secret (HS256, HS384, HS512).\u003C\u002Fstrong> The same secret signs and verifies, so it’s imported as a raw HMAC key. Some providers store the secret Base64-encoded, and HMAC over the text of that Base64 gives a completely different result than HMAC over its decoded bytes. That’s what the “The secret is Base64-encoded” box is for. A wrong choice usually shows up as “Invalid signature”, so when a secret you’re sure of fails, try the box the other way.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>A PEM public key (RS, PS, ES).\u003C\u002Fstrong> The \u003Ccode>-----BEGIN PUBLIC KEY-----\u003C\u002Fcode> block is Base64 around DER bytes in the SPKI format, which Web Crypto imports directly once the header lines and whitespace are removed. Two common mix-ups get their own messages: a private key (“the private key should never leave your server”) and a certificate, which wraps the public key in more structure than SPKI import accepts.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>A JWK.\u003C\u002Fstrong> If the text starts with \u003Ccode>{\u003C\u002Fcode>, it’s parsed as a JSON Web Key and imported as one. This is the form identity providers publish at their JWKS endpoint, so you can copy one key from that list and paste it in.\u003C\u002Fp>\n\u003Ch2 id=\"the-ecdsa-signature-format\">The ECDSA signature format\u003C\u002Fh2>\n\u003Cp>ECDSA signatures have two common encodings. OpenSSL and many server libraries produce DER, a variable-length structure. JWS defines ES256 and its siblings differently (RFC 7518, section 3.4): the two numbers r and s, each padded to a fixed length and simply concatenated. An ES256 signature is always 64 bytes.\u003C\u002Fp>\n\u003Cp>This was the part I expected to need conversion code for, and it didn’t. Web Crypto’s ECDSA uses the same fixed-length r‖s format, so the decoded signature bytes go to \u003Ccode>crypto.subtle.verify\u003C\u002Fcode> as they are. The tests confirm it by signing with Web Crypto, checking with a PEM key, and checking that a key from a different key pair says “invalid”.\u003C\u002Fp>\n\u003Cp>If you sign tokens on a server yourself, this is worth knowing in the other direction. Node’s \u003Ccode>crypto.sign\u003C\u002Fcode> returns DER for EC keys by default, and a JWT built from that output won’t verify anywhere. JWT libraries handle it for you; hand-rolled code often doesn’t.\u003C\u002Fp>\n\u003Ch2 id=\"what-a-valid-signature-does-and-doesn-t-tell-you\">What a valid signature does and doesn’t tell you\u003C\u002Fh2>\n\u003Cp>“Signature verified” means that the header and payload haven’t changed since someone holding that key signed them. It doesn’t mean the token is still in date, meant for your API, or issued by the party you expect. Your server still has to check \u003Ccode>exp\u003C\u002Fcode>, \u003Ccode>iss\u003C\u002Fcode> and \u003Ccode>aud\u003C\u002Fcode> itself, and to choose the algorithm on its side rather than trusting the token’s header. Otherwise an attacker can pick \u003Ccode>none\u003C\u002Fcode>, or an algorithm your code didn’t plan for.\u003C\u002Fp>\n\u003Cp>Some limits of the tool, plainly:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>It verifies HS, RS, PS and ES at 256, 384 and 512. For any other algorithm, such as EdDSA, the page still decodes the token and says which algorithms it can check.\u003C\u002Fli>\n\u003Cli>It can’t decrypt JWE tokens, by design.\u003C\u002Fli>\n\u003Cli>Live tokens are sensitive. The page doesn’t send them anywhere, but a token is a password with an expiry date, so prefer test tokens and don’t share screenshots.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2 id=\"try-it\">Try it\u003C\u002Fh2>\n\u003Cp>Open the \u003Ca href=\"\u002Fjwt-decoder\">JWT decoder\u003C\u002Fa> and press “Try an example”. It signs a fresh HS256 token in your browser, valid for an hour, and fills in its secret, so “Check signature” answers “Signature verified” straight away. Change one character of the payload and check again. To decode the Base64URL parts by hand, the \u003Ca href=\"\u002Fbase64-encode-decode\">Base64 encoder and decoder\u003C\u002Fa> reads URL-safe input too. More developer tools are on the \u003Ca href=\"\u002Fdev-tools\">developer tools\u003C\u002Fa> page.\u003C\u002Fp>\n",[19,23,26,29,32,35,38],{"id":20,"text":21,"level":22},"three-parts-two-of-them-readable-by-anyone","Three parts, two of them readable by anyone",2,{"id":24,"text":25,"level":22},"the-claims-that-decide-whether-a-token-works","The claims that decide whether a token works",{"id":27,"text":28,"level":22},"decoding-isn-t-verifying","Decoding isn’t verifying",{"id":30,"text":31,"level":22},"keys-secrets-pem-and-jwk","Keys: secrets, PEM and JWK",{"id":33,"text":34,"level":22},"the-ecdsa-signature-format","The ECDSA signature format",{"id":36,"text":37,"level":22},"what-a-valid-signature-does-and-doesn-t-tell-you","What a valid signature does and doesn’t tell you",{"id":39,"text":40,"level":22},"try-it","Try it",6,1792325800928]