Verify a JWT before trusting it

An API decodes the JWT it receives and trusts the claims without checking the signature or the algorithm, so an attacker can forge an admin token with alg "none" or sign one with a key the API never issued. Repair the verifier so it confirms the algorithm and signature first, then the claims.

Requires Node.js 22 or newer. No package installation or server needed. To work in a container instead, open the folder with VS Code Dev Containers or GitHub Codespaces: .devcontainer/devcontainer.json uses the Dev Containers Node.js 24 image, pinned by digest.
1. Unzip all files into one folder.
2. In that folder, run: node --test exercise.test.mjs
3. Edit verify-token.mjs, then run the same command again.

The starter deliberately fails some tests. A successful repair passes every case, including cases that catch common wrong fixes. The browser runs this same cases.mjs with the same fixtures; locally Node runs your code directly. If you completed the optional first stage in the browser, Download my work adds your input as stage1-input.json and the tests include it. Only run code you wrote or reviewed.

1. Reproduce the forgery
Run the starter. It splits the token and returns the decoded payload without verifying anything, so the alg-"none" token and the token signed with the rogue key both pass as valid.

2. Confirm the algorithm
Decode the header and require header.alg === config.algorithm. This refuses alg "none" and stops an attacker choosing the algorithm; never read the algorithm from the token to decide how to verify.

3. Verify the signature with the pinned key
Call await crypto.verify(header + "." + payload, signature, config.keyId) and reject when it is false. Use config.keyId, not a kid from the token header, so a token signed with another key is rejected even though its own signature checks out.

4. Validate the claims last
Only after the signature verifies, check iss, aud (a string equal to the audience, or an array with an element equal to it; never a substring match), integer exp greater than now and optional integer nbf no greater than now. Return the payload, or null.

SUPPLIED CONTRACT
verifyToken(token, config, crypto, now) is async and returns the claims object when the token is valid, or null. token is a compact JWS (header.payload.signature, each part base64url). config is {algorithm: "HS256", keyId: "main", issuer, audience}: the server pins the algorithm and the key it issued. crypto.verify(signingInput, signature, keyId) checks the HMAC signature of "<header>.<payload>" with the named key and returns a boolean; it is the only way to check a signature. now is a trusted integer Unix time. Decode the header and payload with atob after converting base64url to base64; the exercise has atob but no Buffer. Reject a token that is not three parts; a header alg that is not exactly config.algorithm (so "none" and any other value are refused); then call crypto.verify with the signing input, the signature and config.keyId — never a key named in the token — and reject when it returns false. Only then validate the claims: iss equals config.issuer, aud equals config.audience or is an array with an element equal to it (a value that only contains it, such as reports-api-internal, is another audience), exp is an integer greater than now, and nbf, if present, is an integer no greater than now. Return the decoded payload on success.

REFERENCE
reference.mjs contains an explained solution: 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.

SCOPE
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.

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?

SOURCE
https://www.rfc-editor.org/rfc/rfc8725.html
