Get unstuck

Hint 1: The token chooses nothing

The algorithm and the key are the server’s decision (config), not the token’s. Require header.alg to equal config.algorithm, and verify with config.keyId.

Hint 2: Verify before you read

Decoding base64url is not verifying. Call crypto.verify and return null when it is false, before you look at any claim.

Reveal the reference patch and explanation
Fixedverify-token.mjsJavaScript
export async function verifyToken(token, config, crypto, now) {
  const parts = token.split('.');
  if (parts.length !== 3) return null;
  const [headerPart, payloadPart, signature] = parts;
  let header, claims;
  try {
    header = JSON.parse(fromBase64Url(headerPart));
    claims = JSON.parse(fromBase64Url(payloadPart));
  } catch {
    return null;
  }
  if (header.alg !== config.algorithm) return null;
  if (!(await crypto.verify(headerPart + '.' + payloadPart, signature, config.keyId))) return null;
  if (claims.iss !== config.issuer) return null;
  const audiences = typeof claims.aud === 'string' ? [claims.aud] : claims.aud;
  if (!Array.isArray(audiences) || !audiences.includes(config.audience)) return null;
  if (!Number.isInteger(claims.exp) || claims.exp <= now) return null;
  if (claims.nbf !== undefined && !(Number.isInteger(claims.nbf) && claims.nbf <= now)) return null;
  return claims;
}

function fromBase64Url(part) {
  const base64 = part.replace(/-/g, '+').replace(/_/g, '/');
  return atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
}

The valid token verifies with the main key and its claims pass, so the payload is returned. The alg-"none" token is refused because its header alg is not HS256, before any signature check. The token signed with the rogue key is refused because config.keyId is main: its own signature is valid, but it is not the key the API issued, so crypto.verify(..., "main") returns false. The tampered token, whose payload was changed after signing, fails the signature check. The expired, wrong-issuer, wrong-audience and not-yet-valid tokens verify cryptographically but fail a claim and return null. An aud array that includes reports-api is accepted; reports-api-internal, reports and an array of near misses are other audiences, which a whole-value comparison rejects and String.prototype.includes on a string aud would accept. A token that is not three parts, or whose header or payload is not valid base64url JSON, returns null.

Compare it with your own fix, then rerun the tests. The editor is not changed when you open this explanation.

Inspect the actual test harness

The browser and the lab download run the same cases, including cases that catch common wrong fixes, such as comparing with one fixture value instead of the real rule. The download includes the complete test adapter and cryptographic fixtures.

Download cases.mjs
How the browser sandbox runs your code

Your code runs in QuickJS 2025-09-13 (quickjs-emscripten 0.32.0) inside a Web Worker, isolated from the page, with a 32 MiB memory limit and a 2.5-second time limit. The test cases and the pass or fail verdict run outside that sandbox, so changing built-ins such as Array.prototype.push cannot change the result. The lab download runs the same cases under Node.js, where your code runs directly.

The same as in current Node.js:

  • ES2025 syntax and built-ins, including Set methods such as intersection, iterator helpers, Promise.try, RegExp.escape and the RegExp v flag

Added to the sandbox so that it behaves like Node.js:

  • structuredClone, Array.fromAsync, queueMicrotask, atob and btoa
  • URL and URLSearchParams, parsed by your browser’s own URL implementation
  • TextEncoder and TextDecoder for UTF-8
  • Intl.Collator and a locale-aware String.prototype.localeCompare
  • console.log and related methods, whose output appears with your results

Not available in the browser sandbox:

  • Timers (setTimeout, setInterval), fetch and other network or storage APIs; the exercises are deterministic and offline
  • Node.js modules and globals: import, require, process and Buffer
  • Web Crypto (crypto.getRandomValues, crypto.subtle); each workshop supplies the cryptographic operations it needs
  • Intl other than Collator: toLocaleString and toLocaleDateString ignore the locale, so numbers have no digit grouping and dates use one fixed format in the device’s time zone
  • using declarations, Symbol.dispose and DisposableStack (explicit resource management)
  • Uint8Array.fromBase64 and toBase64, JSON.rawJSON, Atomics and Error.captureStackTrace

Use the lesson in your own system

The fixtures use HS256 with a pinned key, so the lab shows algorithm confirmation, signature verification with the right key, and claim validation; it does not implement RS256/ES256, JWKS key rollover by kid, or token revocation. A real integration uses a maintained JOSE library, the issuer’s published keys, and its documented access-token profile, and it still applies these application-specific checks. Pinning one algorithm, as here, is the simplest defense against algorithm-confusion attacks.

Your next task: In your own services, is the algorithm to verify with taken from the token header or from your configuration, and which key does the verifier use?

RFC 8725: JSON Web Token Best Current Practices

Related guide: Defend JWTs against forgery, confusion and replay