← Security
Technique Authentication, credentials, and sessions

JWT validation pitfalls

A valid token must still be meant for this API.

An analytics endpoint has started accepting requests with access tokens the billing service issued. That might be a verifier configuration gap, but the report alone does not prove what changed or what data was reached. We’ll compare the token’s intended recipient with the API’s validation policy, then verify the finding using a local fixture.

The skill to keep

Treat token verification as a specific contract: who issued it, who may receive it, what time window applies, and which trusted key and algorithm validate it. Then check permissions for the requested resource separately.

TypeScriptGo One cross-service token · trusted issuer · recipient and claim checks
01 / Read the report

A service accepted a token minted for another service.

At 09:20, an engineer reports that a Billing access token was accepted by Analytics. Start by preserving the request ID, endpoint, verifier configuration, issuer key ID, and a carefully redacted claim summary. Do not copy a live token into chat, a ticket, or an online decoder: a bearer token can itself grant access while it remains valid.

Several explanations fit the initial symptom: the services may intentionally share an audience, Analytics may skip the audience check, a proxy may route to the wrong service, or the request and token may have been misidentified. Compare issuer configuration and endpoint logs before calling it an exploit.

Case file / Service identityA shared identity provider issues tokens to multiple APIs.
Asset
Analytics reports and the service identity accepted at its API boundary.
Caller controls
The bearer token and the request; neither is proof until verified.
Trusted context
The configured issuer, its trusted signing keys, and Analytics’ expected audience.
Invariant
Analytics accepts only valid tokens issued by the trusted authority for Analytics under its explicit claim policy.
01 / InputBearer token

Caller supplies bytes.

02 / VerifyTrusted key + algorithm

Check authenticity with library policy.

03 / ClaimsIssuer + audience + time

Check this issuer and recipient.

04 / AuthorizeReport operation

Decide access to this resource.

A JWT is a structured token format. Its claims are readable by anyone holding it unless a separate encryption scheme is used, so a signed JWT is not a secret container. A valid signature says the token was signed under a key the verifier trusts and that signed bytes were not altered. It does not by itself say that this API is the intended recipient, that the subject may read a particular report, or that the current request is safe.

Leave with: the affected endpoint, the issuer it trusts, its expected audience, and the access rule that must remain true.
02 / Trace the token

Signature validity and recipient validity answer different questions.

The issuer claim (iss) identifies the principal that created the token. The audience claim (aud) identifies the intended recipient or recipients. Expiration (exp) limits when it may be accepted; not-before (nbf) can delay acceptance. Subject (sub) names the represented principal, but the application still needs to interpret that identity and authorize the requested action.

The intended design here gives Billing and Analytics distinct audiences. A token whose aud names Billing must fail at Analytics even if the signature is valid. If the architecture deliberately uses a shared audience, write down why each recipient is meant to accept it and how each API limits claims and permissions.

Carry forward: “cryptographically valid” is only one row in the acceptance contract.
03 / Test the audience

Change one claim in a disposable fixture and observe the verifier.

A useful check isolates the suspected condition. Create a short-lived test token with a trusted fixture key, the expected issuer, and an audience of Billing. Send it only to a local verifier test. Keep the algorithm, subject, timestamps, endpoint, and key constant; change only the audience. If Analytics accepts it, inspect which validation options the active middleware actually uses.

The example below intentionally verifies a signature and allowlists HS256 but omits issuer and audience policy. This is a deliberately incomplete verifier, isolated as code for review; it is not a deployable endpoint. Its expected subject alone should not be mistaken for a complete security test.

Read the verifier in your languages.

Both examples use established JWT libraries and the same test contract.

TypeScriptIntentionally incomplete · local fixture only
jwt-validation.ts · missing recipient check
export async function readSubjectVulnerable(token: string): Promise<string> {
	// The signature is checked, but the verifier never asks who issued the token
	// or whether this API is one of its intended recipients.
	const { payload } = await jwtVerify(token, fixtureKey, { algorithms: ['HS256'] });
	if (typeof payload.sub !== 'string') throw new Error('subject required');
	return payload.sub;
}
GoIntentionally incomplete · local fixture only
jwt-validation.go · missing recipient check
func readSubjectVulnerable(raw string) (string, error) {
	claims := &Claims{}
	token, err := jwt.ParseWithClaims(raw, claims, func(token *jwt.Token) (any, error) {
		if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
			return nil, errors.New("unexpected signing method")
		}
		return fixtureKey, nil
	}, jwt.WithValidMethods([]string{"HS256"}))
	if err != nil || token == nil || !token.Valid {
		return "", errors.New("invalid token")
	}
	// Signature, algorithm, and present time claims were checked, but this API's
	// issuer and intended audience were not.
	return claims.Subject, nil
}
Diagnostic checkpointSeparate the observation from the suspected cause.
Observation
The incomplete verifier returns user-42 for a valid Billing token.
Hypotheses
The verifier omits audience policy; the services intentionally share an audience; or the test is exercising a different verifier than production.
Discriminating check
Hold signer, algorithm, issuer, subject, and time fixed; mint tokens for Billing and Analytics; run both through the exact configured verifier and record accept/reject outcomes without logging full tokens.
What does this reproduction prove?Reveal after recording your diagnosis

It demonstrates that a valid signature plus a present subject is insufficient in this verifier: the intended recipient is not checked. It does not demonstrate access to customer data, privilege escalation, token theft, or a production incident. Those require separate evidence about routes, claims, authorization, caller reachability, and data access.

Leave with: one fixture that differs only in audience and an observation from the verifier under investigation.
04 / Validate for this API

Make the acceptance policy explicit at the trusted boundary.

Use a maintained JWT library to verify the signature with keys obtained through the issuer’s documented trust and rotation process. Configure the allowed algorithm, expected issuer, and this API’s audience. Require the claims your application depends on; validate their time semantics and reasonable token age. Reject missing, malformed, expired, not-yet-valid, wrong-issuer, wrong-audience, and unverifiable tokens.

The sample uses a fixture HS256 key to make both language examples self-contained. A real multi-service deployment commonly verifies asymmetric issuer signatures through a trusted JWKS or platform identity library. Do not share a symmetric signing secret casually across services: every verifier holding that secret may also be able to mint tokens. Follow the identity provider’s key and algorithm guidance.

TypeScriptLibrary-backed validation · explicit policy
jwt-validation.ts · issuer and audience checks
export async function readSubjectForAnalytics(token: string): Promise<string> {
	const { payload } = await jwtVerify(token, fixtureKey, {
		algorithms: ['HS256'],
		issuer,
		audience: analyticsAudience,
		requiredClaims: ['sub', 'exp', 'iat'],
		maxTokenAge: '10m'
	});
	if (typeof payload.sub !== 'string' || payload.sub.length === 0) {
		throw new Error('subject required');
	}
	return payload.sub;
}
GoLibrary-backed validation · explicit policy
jwt-validation.go · issuer and audience checks
func readSubjectForAnalytics(raw string) (string, error) {
	claims := &Claims{}
	token, err := jwt.ParseWithClaims(raw, claims, func(token *jwt.Token) (any, error) {
		if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
			return nil, errors.New("unexpected signing method")
		}
		return fixtureKey, nil
	},
		jwt.WithValidMethods([]string{"HS256"}),
		jwt.WithIssuer(issuer),
		jwt.WithAudience(analyticsAudience),
		jwt.WithExpirationRequired(),
		jwt.WithIssuedAt(),
	)
	if err != nil || token == nil || !token.Valid || claims.Subject == "" || claims.IssuedAt == nil {
		return "", errors.New("invalid token")
	}
	return claims.Subject, nil
}
Leave with: the API’s configured trust anchors and the exact issuer, audience, algorithm, and claim rules it enforces.
05 / Verify the claims

Test the rejection boundary and the allowed request.

A useful regression set includes a valid Analytics token that passes and a valid Billing token that fails. Also cover a wrong issuer, expired token, not-yet-valid token, missing expiration or subject, altered signature, and an algorithm outside the configured allowlist. Assert the verifier result and the protected handler’s side effect: invalid tokens must not reach it.

Keep authentication and authorization cases distinct. A correctly issued Analytics token can still name a user who may not access a particular tenant’s report. Test that resource boundary with an authenticated subject and a denied permission, too.

Read the complete isolated fixturesIncludes token setup; fixture secrets are not production credentials
jwt-validation.ts · complete fixture
import { jwtVerify, SignJWT } from 'jose';

// Fixture only. Production keys should be provisioned and rotated by the trusted issuer.
const fixtureKey = new TextEncoder().encode('fixture-only-secret-with-at-least-32-bytes');
const issuer = 'https://identity.example.test/';
const analyticsAudience = 'https://analytics.example.test';
const billingAudience = 'https://billing.example.test';

export async function issueFixture(audience: string) {
	return new SignJWT({ scope: 'report:read' })
		.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
		.setIssuer(issuer)
		.setAudience(audience)
		.setSubject('user-42')
		.setIssuedAt()
		.setExpirationTime('5m')
		.sign(fixtureKey);
}

export async function readSubjectVulnerable(token: string): Promise<string> {
	// The signature is checked, but the verifier never asks who issued the token
	// or whether this API is one of its intended recipients.
	const { payload } = await jwtVerify(token, fixtureKey, { algorithms: ['HS256'] });
	if (typeof payload.sub !== 'string') throw new Error('subject required');
	return payload.sub;
}

export async function readSubjectForAnalytics(token: string): Promise<string> {
	const { payload } = await jwtVerify(token, fixtureKey, {
		algorithms: ['HS256'],
		issuer,
		audience: analyticsAudience,
		requiredClaims: ['sub', 'exp', 'iat'],
		maxTokenAge: '10m'
	});
	if (typeof payload.sub !== 'string' || payload.sub.length === 0) {
		throw new Error('subject required');
	}
	return payload.sub;
}

export async function demonstrateAudienceMismatch() {
	const billingToken = await issueFixture(billingAudience);
	const vulnerableSubject = await readSubjectVulnerable(billingToken);
	let fixedRejected = false;
	try {
		await readSubjectForAnalytics(billingToken);
	} catch {
		fixedRejected = true;
	}
	return { vulnerableSubject, fixedRejected };
}
jwt-validation.go · complete fixture
package main

import (
	"errors"
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

// Fixture only. Production keys should be provisioned and rotated by the trusted issuer.
var fixtureKey = []byte("fixture-only-secret-with-at-least-32-bytes")

const (
	issuer            = "https://identity.example.test/"
	analyticsAudience = "https://analytics.example.test"
	billingAudience   = "https://billing.example.test"
)

type Claims struct {
	Scope string `json:"scope,omitempty"`
	jwt.RegisteredClaims
}

func issueFixture(audience string) (string, error) {
	claims := Claims{
		Scope: "report:read",
		RegisteredClaims: jwt.RegisteredClaims{
			Issuer: issuer, Subject: "user-42", Audience: jwt.ClaimStrings{audience},
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
		},
	}
	return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(fixtureKey)
}

func readSubjectVulnerable(raw string) (string, error) {
	claims := &Claims{}
	token, err := jwt.ParseWithClaims(raw, claims, func(token *jwt.Token) (any, error) {
		if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
			return nil, errors.New("unexpected signing method")
		}
		return fixtureKey, nil
	}, jwt.WithValidMethods([]string{"HS256"}))
	if err != nil || token == nil || !token.Valid {
		return "", errors.New("invalid token")
	}
	// Signature, algorithm, and present time claims were checked, but this API's
	// issuer and intended audience were not.
	return claims.Subject, nil
}


func readSubjectForAnalytics(raw string) (string, error) {
	claims := &Claims{}
	token, err := jwt.ParseWithClaims(raw, claims, func(token *jwt.Token) (any, error) {
		if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
			return nil, errors.New("unexpected signing method")
		}
		return fixtureKey, nil
	},
		jwt.WithValidMethods([]string{"HS256"}),
		jwt.WithIssuer(issuer),
		jwt.WithAudience(analyticsAudience),
		jwt.WithExpirationRequired(),
		jwt.WithIssuedAt(),
	)
	if err != nil || token == nil || !token.Valid || claims.Subject == "" || claims.IssuedAt == nil {
		return "", errors.New("invalid token")
	}
	return claims.Subject, nil
}


func demonstrateAudienceMismatch() error {
	billingToken, err := issueFixture(billingAudience)
	if err != nil {
		return err
	}
	if _, err := readSubjectVulnerable(billingToken); err != nil {
		return fmt.Errorf("expected vulnerable example to accept fixture: %w", err)
	}
	if _, err := readSubjectForAnalytics(billingToken); err == nil {
		return errors.New("fixed verifier accepted a token for billing")
	}
	return nil
}
Leave with: a test matrix that proves both intended acceptance and rejection, plus no protected side effect for rejected credentials.
06 / Make the next call

Choose a check that answers the question you actually have.

Try a decision

The token has a valid signature but names Billing as its audience. What should Analytics do?

Assume the issuer and key are trusted and the system intends separate recipients. Pick the next action that protects the boundary and produces useful evidence.

Your next move

The transferable question is: what specific fact makes this credential acceptable here? Trace that fact to trusted configuration and enforce it with a verifier designed for the token profile. When investigating a failure, vary one condition at a time and observe the actual verifier and protected operation.

Sources checked 2026-10-01: RFC 7519, JSON Web Token defines issuer, audience, subject, and time claims. RFC 8725, JWT Best Current Practice recommends explicit algorithm verification and audience validation. jose jwtVerify docs and golang-jwt/jwt v5 docs document library-backed claim verification.