A self-contained token carries its own answer. Instead of a random string the server looks up, it holds the claims inside it: who the user is, what role they have, when it stops being valid. The server reads those claims and trusts them, because a signature says they have not been changed. Chapter V9 Self-contained Tokens of the OWASP Application Security Verification Standard has four Level 1 requirements, and every one of them is about the moment before you trust the claims.
This part covers all four with runnable code in Node.js with Fastify and in Go with Fiber. Pick your stack once and the whole article follows it. Every control is a broken version next to a fixed version, one small change apart, and each broken version is defeated by a real forged token you build yourself.
Part 9 used reference tokens, which are random strings with the answer kept on the server. This part is the other option, and it trades a lookup for a signature check that has to be exactly right.
Conceptual Overview
A JSON Web Token (JWT) is three pieces of text joined by dots. The first is a header that says which algorithm signed the token. The second is the payload, which holds the claims. The third is the signature. The first two are base64url encoded, not encrypted: anybody holding the token can read every claim in it with one shell command. Never put a secret in a JWT.
The signature is a Message Authentication Code (MAC) over the first two pieces. With a symmetric algorithm such as HS256, one shared secret both makes and checks the signature. With an asymmetric algorithm such as RS256, a private key signs and a matching public key verifies, so the verifier never holds anything that lets it forge.
Every one of these four requirements exists because the token tells you how to check it. The header is attacker-controlled text sitting right next to the data it describes. If your code reads alg and does what it says, or fetches a key from a URL the token supplied, then the attacker chose the check. The whole chapter reduces to one habit: the verifier decides.
Decoding is not verifying. Every JWT library ships a decode function that splits the token and parses the claims with no cryptography at all. It exists for debugging. Calling it in a request handler is the most common JWT bug there is, and it fails open: the code looks right, the tests pass, and any user can hand you any claims they like.
These four checks are the floor, not the whole job. A verified token still needs its claims checked against your application: is this the right issuer, the right audience, a subject that still exists, a role that is still granted? Part 10 covers what you do with the answer.
Prerequisites
- Ubuntu 24.04 LTS with
sudoaccess. - Node.js 22 or later, or Go 1.25 or later, from Build a REST API with Fastify and MySQL on Ubuntu or Build a REST API in Go with Fiber v3 on Ubuntu.
curlandpython3, both already on Ubuntu. The forgery tools are Python because an attacker does not use your framework.- JWT Authentication and Middleware in a Go API on Ubuntu if you have never issued a token before.
- Somewhere to record what you close. Part 1 lists all 70 Level 1 requirements.
Step 1: Issue a Real Token to Attack
Every example verifies a token for [email protected] whose role claim is member. The goal of each attack is to reach the same endpoint as admin.
mkdir -p ~/asvs-v9/node
cd ~/asvs-v9/node
npm init -y
npm pkg set type=module
npm install fastify jose
Create keys.mjs to make an RSA key pair once:
import { generateKeyPair, exportPKCS8, exportSPKI } from 'jose'
import { writeFile } from 'node:fs/promises'
const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true })
await writeFile('private.pem', await exportPKCS8(privateKey))
await writeFile('public.pem', await exportSPKI(publicKey))
console.log('wrote private.pem and public.pem')
Create issue.mjs, which stands in for your identity provider:
import { SignJWT, importPKCS8 } from 'jose'
import { readFile } from 'node:fs/promises'
const key = await importPKCS8(await readFile('private.pem', 'utf8'), 'RS256')
const token = await new SignJWT({ role: process.argv[2] ?? 'member' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuer('https://auth.example.com')
.setSubject('[email protected]')
.setIssuedAt()
.setExpirationTime(process.argv[3] ?? '15m')
.sign(key)
console.log(token)
Make the keys and one honest token:
node keys.mjs
node issue.mjs member 15m > token.txt
Run a server with node sig-bad.mjs, and stop it with Ctrl+C before starting the next one.
mkdir -p ~/asvs-v9/go
cd ~/asvs-v9/go
go mod init asvs-v9
go get github.com/gofiber/fiber/[email protected]
go get github.com/golang-jwt/jwt/v5
Create cmd/keys/main.go to make an RSA key pair once:
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"os"
)
func write(name, blockType string, der []byte) {
f, err := os.Create(name)
if err != nil {
log.Fatal(err)
}
defer f.Close()
if err := pem.Encode(f, &pem.Block{Type: blockType, Bytes: der}); err != nil {
log.Fatal(err)
}
}
func main() {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
log.Fatal(err)
}
priv, _ := x509.MarshalPKCS8PrivateKey(key)
pub, _ := x509.MarshalPKIXPublicKey(&key.PublicKey)
write("private.pem", "PRIVATE KEY", priv)
write("public.pem", "PUBLIC KEY", pub)
fmt.Println("wrote private.pem and public.pem")
}
Create cmd/issue/main.go, which stands in for your identity provider:
package main
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
func main() {
role := "member"
if len(os.Args) > 1 {
role = os.Args[1]
}
ttl := 15 * time.Minute
if len(os.Args) > 2 {
var err error
if ttl, err = time.ParseDuration(os.Args[2]); err != nil {
log.Fatal(err)
}
}
raw, err := os.ReadFile("private.pem")
if err != nil {
log.Fatal(err)
}
block, _ := pem.Decode(raw)
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
log.Fatal(err)
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"role": role,
"iss": "https://auth.example.com",
"sub": "[email protected]",
"iat": time.Now().Unix(),
"exp": time.Now().Add(ttl).Unix(),
})
signed, err := token.SignedString(parsed.(*rsa.PrivateKey))
if err != nil {
log.Fatal(err)
}
fmt.Println(signed)
}
Build everything into bin/, then make the keys and one honest token:
go build -o bin/ ./...
./bin/keys
./bin/issue member 15m > token.txt
Run a server with ./bin/sig-bad, and stop it with Ctrl+C before starting the next one. A leftover server still holding port 3000 will answer requests you think are going to the new one.
Look inside the token before attacking it, so it is clear how little protection the encoding gives:
cut -d. -f2 token.txt | python3 -c \
"import base64,sys; p=sys.stdin.read().strip(); print(base64.urlsafe_b64decode(p+'='*(-len(p)%4)).decode())"
{"role":"member","iss":"https://auth.example.com","sub":"[email protected]","iat":1787630621,"exp":1787631521}
Step 2: Check the Signature Before You Read the Claims
V9.1.1 Verify that self-contained tokens are validated using their digital signature or MAC to protect against tampering before accepting the token’s contents.
The broken server decodes the token and uses the claims. It never touches the key.
Create sig-bad.mjs:
import Fastify from 'fastify'
import { decodeJwt } from 'jose'
const app = Fastify()
app.get('/whoami', async (req, reply) => {
const token = (req.headers.authorization ?? '').replace('Bearer ', '')
try {
const claims = decodeJwt(token)
return { sub: claims.sub, role: claims.role, isAdmin: claims.role === 'admin' }
} catch {
return reply.code(401).send({ error: 'bad token' })
}
})
await app.listen({ port: 3000 })
Create sig-good.mjs. The only real change is decodeJwt becoming jwtVerify, which needs the public key:
import Fastify from 'fastify'
import { jwtVerify, importSPKI } from 'jose'
import { readFile } from 'node:fs/promises'
const key = await importSPKI(await readFile('public.pem', 'utf8'), 'RS256')
const app = Fastify()
app.get('/whoami', async (req, reply) => {
const token = (req.headers.authorization ?? '').replace('Bearer ', '')
try {
const { payload: claims } = await jwtVerify(token, key)
return { sub: claims.sub, role: claims.role, isAdmin: claims.role === 'admin' }
} catch {
return reply.code(401).send({ error: 'bad token' })
}
})
await app.listen({ port: 3000 })
Create cmd/sig-bad/main.go:
package main
import (
"log"
"strings"
"github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5"
)
type identity struct {
Sub string `json:"sub"`
Role string `json:"role"`
IsAdmin bool `json:"isAdmin"`
}
func main() {
app := fiber.New()
app.Get("/whoami", func(c fiber.Ctx) error {
raw := strings.TrimPrefix(c.Get("Authorization"), "Bearer ")
claims := jwt.MapClaims{}
if _, _, err := jwt.NewParser().ParseUnverified(raw, claims); err != nil {
return c.Status(401).JSON(fiber.Map{"error": "bad token"})
}
sub, _ := claims.GetSubject()
role, _ := claims["role"].(string)
return c.JSON(identity{Sub: sub, Role: role, IsAdmin: role == "admin"})
})
log.Fatal(app.Listen(":3000"))
}
Create cmd/sig-good/main.go. The only real change is ParseUnverified becoming ParseWithClaims, which needs the public key:
package main
import (
"crypto/x509"
"encoding/pem"
"log"
"os"
"strings"
"github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5"
)
type identity struct {
Sub string `json:"sub"`
Role string `json:"role"`
IsAdmin bool `json:"isAdmin"`
}
func main() {
raw, err := os.ReadFile("public.pem")
if err != nil {
log.Fatal(err)
}
block, _ := pem.Decode(raw)
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
log.Fatal(err)
}
app := fiber.New()
app.Get("/whoami", func(c fiber.Ctx) error {
token := strings.TrimPrefix(c.Get("Authorization"), "Bearer ")
claims := jwt.MapClaims{}
_, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
return pub, nil
})
if err != nil {
return c.Status(401).JSON(fiber.Map{"error": "bad token"})
}
sub, _ := claims.GetSubject()
role, _ := claims["role"].(string)
return c.JSON(identity{Sub: sub, Role: role, IsAdmin: role == "admin"})
})
log.Fatal(app.Listen(":3000"))
}
Now forge a token. This one is trivial: keep the header and the signature, edit the payload. Create tamper.py:
import base64, json, sys
def dec(part):
return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))
def enc(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
header, payload, signature = sys.stdin.read().strip().split(".")
claims = json.loads(dec(payload))
claims["role"] = "admin"
print(f"{header}.{enc(json.dumps(claims).encode())}.{signature}")
python3 tamper.py < token.txt > forged.txt
curl -s -w " HTTP %{http_code}\n" -H "Authorization: Bearer $(cat forged.txt)" \
localhost:3000/whoami
The broken server promotes Alice, because the signature no longer matches and nothing looked:
{"sub":"[email protected]","role":"admin","isAdmin":true} HTTP 200
The fixed server rejects the forgery and still accepts the honest token:
{"error":"bad token"} HTTP 401
{"sub":"[email protected]","role":"member","isAdmin":false} HTTP 200
Notice both failures return the same bad token message. Telling the caller which check failed helps nobody except the person trying to get past it.
Step 3: Decide the Algorithm Yourself
V9.1.2 Verify that only algorithms on an allowlist can be used to create and verify self-contained tokens, for a given context. The allowlist must include the permitted algorithms, ideally only either symmetric or asymmetric algorithms, and must not include the ‘None’ algorithm. If both symmetric and asymmetric must be supported, additional controls will be needed to prevent key confusion.
Both libraries used here refuse the None algorithm outright, so that classic attack is already closed for you. The half that is still wide open is the last sentence: key confusion, which is what happens when the verifier picks the key by looking at the token’s own alg header.
The broken server supports two kinds of token, an old symmetric one and the new RS256 one, and chooses by algorithm. That is a real refactor people write during a migration.
Create alg-bad.mjs:
import Fastify from 'fastify'
import { jwtVerify, importSPKI, decodeProtectedHeader } from 'jose'
import { readFile } from 'node:fs/promises'
const pem = await readFile('public.pem', 'utf8')
const app = Fastify()
app.get('/whoami', async (req, reply) => {
const token = (req.headers.authorization ?? '').replace('Bearer ', '')
try {
const { alg } = decodeProtectedHeader(token)
const key = alg.startsWith('HS')
? new TextEncoder().encode(pem)
: await importSPKI(pem, alg)
const { payload: claims } = await jwtVerify(token, key)
return { sub: claims.sub, role: claims.role, isAdmin: claims.role === 'admin' }
} catch {
return reply.code(401).send({ error: 'bad token' })
}
})
await app.listen({ port: 3000 })
Copy the file to alg-good.mjs. Import the key once, at startup, and name the algorithm you accept:
const key = await importSPKI(await readFile('public.pem', 'utf8'), 'RS256')
app.get('/whoami', async (req, reply) => {
const token = (req.headers.authorization ?? '').replace('Bearer ', '')
try {
const { payload: claims } = await jwtVerify(token, key, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
})
return { sub: claims.sub, role: claims.role, isAdmin: claims.role === 'admin' }
} catch {
return reply.code(401).send({ error: 'bad token' })
}
})
Create cmd/alg-bad/main.go. It is sig-good with the key function replaced:
keyFor := func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); ok {
return raw, nil
}
return pub, nil
}
app.Get("/whoami", func(c fiber.Ctx) error {
token := strings.TrimPrefix(c.Get("Authorization"), "Bearer ")
claims := jwt.MapClaims{}
if _, err := jwt.ParseWithClaims(token, claims, keyFor); err != nil {
return c.Status(401).JSON(fiber.Map{"error": "bad token"})
}
sub, _ := claims.GetSubject()
role, _ := claims["role"].(string)
return c.JSON(identity{Sub: sub, Role: role, IsAdmin: role == "admin"})
})
Copy the directory to cmd/alg-good. Return one key whatever the token says, and name the algorithm you accept:
keyFor := func(t *jwt.Token) (any, error) { return pub, nil }
app.Get("/whoami", func(c fiber.Ctx) error {
token := strings.TrimPrefix(c.Get("Authorization"), "Bearer ")
claims := jwt.MapClaims{}
_, err := jwt.ParseWithClaims(token, claims, keyFor,
jwt.WithValidMethods([]string{"RS256"}),
jwt.WithIssuer("https://auth.example.com"))
if err != nil {
return c.Status(401).JSON(fiber.Map{"error": "bad token"})
}
sub, _ := claims.GetSubject()
role, _ := claims["role"].(string)
return c.JSON(identity{Sub: sub, Role: role, IsAdmin: role == "admin"})
})
The attack: an RS256 public key is public. Sign an HS256 token using the text of public.pem as the shared secret, and the broken server will happily verify it against the same text. Create confuse.py:
import base64, hashlib, hmac, json, time
def enc(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
secret = open("public.pem", "rb").read()
header = enc(json.dumps({"alg": "HS256"}).encode())
claims = enc(
json.dumps(
{
"role": "admin",
"iss": "https://auth.example.com",
"sub": "[email protected]",
"exp": int(time.time()) + 900,
}
).encode()
)
signature = enc(hmac.new(secret, f"{header}.{claims}".encode(), hashlib.sha256).digest())
print(f"{header}.{claims}.{signature}")
python3 confuse.py > confused.txt
curl -s -w " HTTP %{http_code}\n" -H "Authorization: Bearer $(cat confused.txt)" \
localhost:3000/whoami
The broken server verifies a signature the attacker made with public information:
{"sub":"[email protected]","role":"admin","isAdmin":true} HTTP 200
The fixed server never gets that far, because the token says HS256 and the allowlist says RS256:
{"error":"bad token"} HTTP 401
{"sub":"[email protected]","role":"member","isAdmin":false} HTTP 200
Read the requirement again with that output in mind. “Ideally only either symmetric or asymmetric” is not a style preference. Supporting one family removes this entire attack, because there is no second interpretation of the key.
Step 4: Take the Key From Your Own Configuration
V9.1.3 Verify that key material that is used to validate self-contained tokens is from trusted pre-configured sources for the token issuer, preventing attackers from specifying untrusted sources and keys. For JWTs and other JWS structures, headers such as ‘jku’, ‘x5u’, and ‘jwk’ must be validated against an allowlist of trusted sources.
Three JWT header fields can carry key material or a pointer to it. jwk embeds a public key directly in the token. jku is a URL to a key set. x5u is a URL to a certificate chain. A verifier that follows any of them is asking the attacker which key to use.
The broken server reads the jwk header, which looks helpful: tokens become self-describing and key rotation gets easy.
Create key-bad.mjs:
import Fastify from 'fastify'
import { jwtVerify, importJWK, decodeProtectedHeader } from 'jose'
const app = Fastify()
app.get('/whoami', async (req, reply) => {
const token = (req.headers.authorization ?? '').replace('Bearer ', '')
try {
const header = decodeProtectedHeader(token)
const key = await importJWK(header.jwk, header.alg)
const { payload: claims } = await jwtVerify(token, key, { algorithms: ['RS256'] })
return { sub: claims.sub, role: claims.role, isAdmin: claims.role === 'admin' }
} catch {
return reply.code(401).send({ error: 'bad token' })
}
})
await app.listen({ port: 3000 })
Copy the file to key-good.mjs and delete the two lines that came from the token:
const key = await importSPKI(await readFile('public.pem', 'utf8'), 'RS256')
app.get('/whoami', async (req, reply) => {
const token = (req.headers.authorization ?? '').replace('Bearer ', '')
try {
const { payload: claims } = await jwtVerify(token, key, { algorithms: ['RS256'] })
return { sub: claims.sub, role: claims.role, isAdmin: claims.role === 'admin' }
} catch {
return reply.code(401).send({ error: 'bad token' })
}
})
Now build a token that carries its own key. Create evil.mjs:
import { SignJWT, generateKeyPair, exportJWK } from 'jose'
const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true })
const token = await new SignJWT({ role: 'admin' })
.setProtectedHeader({ alg: 'RS256', jwk: await exportJWK(publicKey) })
.setIssuer('https://auth.example.com')
.setSubject('[email protected]')
.setExpirationTime('15m')
.sign(privateKey)
console.log(token)
node evil.mjs > evil.txt
Create cmd/key-bad/main.go. The key function builds an RSA public key out of the token’s own header:
func keyFromHeader(t *jwt.Token) (any, error) {
jwk, ok := t.Header["jwk"].(map[string]any)
if !ok {
return nil, errors.New("no jwk header")
}
n, err := base64.RawURLEncoding.DecodeString(jwk["n"].(string))
if err != nil {
return nil, err
}
e, err := base64.RawURLEncoding.DecodeString(jwk["e"].(string))
if err != nil {
return nil, err
}
return &rsa.PublicKey{
N: new(big.Int).SetBytes(n),
E: int(new(big.Int).SetBytes(e).Int64()),
}, nil
}
Copy the directory to cmd/key-good and throw that function away. The key comes from the file you loaded at startup, exactly as in alg-good:
keyFor := func(t *jwt.Token) (any, error) { return pub, nil }
Now build a token that carries its own key. Create cmd/evil/main.go:
package main
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/binary"
"fmt"
"log"
"time"
"github.com/golang-jwt/jwt/v5"
)
func main() {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
log.Fatal(err)
}
e := make([]byte, 4)
binary.BigEndian.PutUint32(e, uint32(key.PublicKey.E))
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"role": "admin",
"iss": "https://auth.example.com",
"sub": "[email protected]",
"exp": time.Now().Add(15 * time.Minute).Unix(),
})
token.Header["jwk"] = map[string]string{
"kty": "RSA",
"n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(e[1:]),
}
signed, err := token.SignedString(key)
if err != nil {
log.Fatal(err)
}
fmt.Println(signed)
}
go build -o bin/ ./...
./bin/evil > evil.txt
That token is signed correctly. It just is not signed by you:
curl -s -w " HTTP %{http_code}\n" -H "Authorization: Bearer $(cat evil.txt)" \
localhost:3000/whoami
The broken server checks the signature, passes, and hands over an admin session:
{"sub":"[email protected]","role":"admin","isAdmin":true} HTTP 200
The fixed server checks it against your public key, which is a different key:
{"error":"bad token"} HTTP 401
{"sub":"[email protected]","role":"member","isAdmin":false} HTTP 200
Fetching a key set over HTTPS from your identity provider is normal and fine. What makes it safe is that the URL comes from your configuration file, not from the token. If you genuinely serve several issuers, keep a fixed map from issuer name to key set URL and reject any token whose iss is not a key in that map.
Step 5: Enforce the Validity Window
V9.2.1 Verify that, if a validity time span is present in the token data, the token and its content are accepted only if the verification time is within this validity time span. For example, for JWTs, the claims ‘nbf’ and ‘exp’ must be verified.
exp is the second after which the token is dead. nbf, “not before”, is the second before which it is not yet alive. Both libraries check them automatically. The bug is code that switches the checking off, usually to make a clock skew problem or a failing test go away.
Create exp-bad.mjs. It verifies the signature properly, using the lower level compactVerify, which handles the JSON Web Signature and knows nothing about claims:
import { compactVerify, importSPKI } from 'jose'
app.get('/whoami', async (req, reply) => {
const token = (req.headers.authorization ?? '').replace('Bearer ', '')
try {
const { payload } = await compactVerify(token, key)
const claims = JSON.parse(new TextDecoder().decode(payload))
return { sub: claims.sub, role: claims.role, isAdmin: claims.role === 'admin' }
} catch {
return reply.code(401).send({ error: 'bad token' })
}
})
Copy the file to exp-good.mjs. Use jwtVerify, which checks exp and nbf, and demand that exp is actually there:
import { jwtVerify, importSPKI } from 'jose'
app.get('/whoami', async (req, reply) => {
const token = (req.headers.authorization ?? '').replace('Bearer ', '')
try {
const { payload: claims } = await jwtVerify(token, key, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
requiredClaims: ['exp'],
})
return { sub: claims.sub, role: claims.role, isAdmin: claims.role === 'admin' }
} catch {
return reply.code(401).send({ error: 'bad token' })
}
})
Create cmd/exp-bad/main.go. It is alg-good with the claim checking turned off:
_, err := jwt.ParseWithClaims(token, claims, keyFor,
jwt.WithValidMethods([]string{"RS256"}),
jwt.WithoutClaimsValidation())
Copy the directory to cmd/exp-good. Leave claim validation on, and demand that exp is actually there:
_, err := jwt.ParseWithClaims(token, claims, keyFor,
jwt.WithValidMethods([]string{"RS256"}),
jwt.WithIssuer("https://auth.example.com"),
jwt.WithExpirationRequired())
No forging needed for this one. Issue a token that expired an hour ago:
node issue.mjs member -1h > expired.txt
./bin/issue member -1h > expired.txt
curl -s -w " HTTP %{http_code}\n" -H "Authorization: Bearer $(cat expired.txt)" \
localhost:3000/whoami
The broken server accepts a dead token, which turns a fifteen minute session into a permanent one:
{"sub":"[email protected]","role":"member","isAdmin":false} HTTP 200
The fixed server refuses it, and the fresh token still works:
{"error":"bad token"} HTTP 401
{"sub":"[email protected]","role":"member","isAdmin":false} HTTP 200
requiredClaims and WithExpirationRequired are worth the extra line. Without them a token with no exp at all satisfies the check trivially, since there is no validity span to fall outside of.
Common Mistakes and Troubleshooting
Using the decode helper in a handler. decodeJwt, ParseUnverified, and jwt.decode in other languages all skip the signature. Search your codebase for them and make sure every hit is a script or a test.
Verifying, then reading the claims from a second decode. Some code verifies the token and then calls the decode helper to get the payload, which is harmless until somebody deletes the verify call. Read the claims that the verify function returned.
Letting the client choose the issuer. Checking iss against a list is good. Using iss to look up a key set URL from data the token supplied is the Step 4 bug with extra steps.
Trusting a token you cannot revoke. A self-contained token is valid until it expires, whatever happens to the account. Keep the lifetime short, minutes rather than days, and keep a deny list of token identifiers for the cases where you must cut someone off now.
A clock that drifts. If verification fails on one server and passes on another, check the time before you touch the code. Install chrony and let both machines agree. Do not solve it by widening the tolerance to hours.
Putting anything private in the payload. It is base64, and Step 1 decoded it with one command. Names, email addresses, and internal identifiers in a token end up in browser storage, proxy logs, and error trackers.
Best Practices
Verify in one function and call it everywhere. A single verifyToken helper with the key, the algorithm list, the issuer, and the audience baked in is much harder to get wrong than options repeated per route.
Prefer asymmetric keys for anything crossing a service boundary. The verifying service holds only a public key, so a compromise there does not let the attacker mint tokens.
Pin the algorithm to exactly one value. Not a family, not a list of five. One. Add the second only when a real rotation needs it, and remove it afterwards.
Set exp when you issue and require it when you verify. Both halves, or the check does nothing.
Rotate signing keys on a schedule and use kid to pick between them. Use kid only as a lookup into your own configured key map, never as a filename or a URL.
Test the failures, not just the success. A test suite that only checks a valid token proves nothing. Add one test per attack in this article: a tampered payload, a wrong algorithm, a token carrying its own key, and an expired token.
Conclusion
Chapter V9 is four requirements and one idea: the token does not get to describe how it is checked. You verified the signature before reading a single claim, pinned the algorithm to RS256 so a public key cannot be reused as a shared secret, took the key from your own file instead of from the token’s header, and enforced the expiry.
The three forgeries in this article each took about ten lines of Python, and each of them turned a member into an admin against code that looked finished. Run them again whenever you change your verification code. Mark V9.1.1, V9.1.2, V9.1.3, and V9.2.1 as passed in your own record.
Part 12 covers chapter V10 OAuth and OIDC, where all five Level 1 requirements land on the authorization server rather than on your application code.
Requirement text quoted from the OWASP Application Security Verification Standard 5.0.0, used under CC BY-SA 4.0.