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.
- 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.
Caller supplies bytes.
Check authenticity with library policy.
Check this issuer and recipient.
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.
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.
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.
Both examples use established JWT libraries and the same test contract.
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;
} 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
} - Observation
- The incomplete verifier returns
user-42for 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.
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.
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;
} 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
} 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
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 };
}
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
}
Choose a check that answers the question you actually have.
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.
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.