How a JWT Audience Map Saved a CORS Mistake: A Defense-in-Depth Case Study

While pen-testing a client's API, we found a CORS policy that accepted any subdomain of the client's main domain, with credentials, on every kind of HTTP request. Textbook misconfiguration. We were drafting it as a HIGH severity finding when we tried to run the exploit ourselves, and discovered a second, independent control that stopped the attack at a different layer.

Two terms before we go further:

  • CORS — the browser's rule about which other websites are allowed to send authenticated requests to your API. Mis-set, it lets any site act on a logged-in user's behalf.
  • JWT — JSON Web Token. The short signed string the API hands a user after they log in. Every subsequent request carries it as proof of who the user is. The token has an audience field that names which application the token was minted for, and the API can refuse a token presented to the wrong audience.

Two independent controls — the stricter one held while the weaker one was broken.

The CORS Misconfiguration

The Express CORS middleware allowed any subdomain through a regex:

// cors.loader.ts
const pattern = /^https:\/\/([a-z0-9-]+\.)?client\.example\.org$/;

This matches any subdomain, including ones that do not exist yet, ones that could be registered by attackers, and ones that could be taken over via a dangling DNS record. We confirmed the breadth with a small matrix:

# Evil subdomain — accepted
curl -sI "$API/api/config" \
  -H "Origin: https://evil.client.example.org"
# access-control-allow-origin: https://evil.client.example.org
# access-control-allow-credentials: true

# Punycode subdomain — accepted
curl -sI "$API/api/config" \
  -H "Origin: https://xn--evil.client.example.org"
# access-control-allow-origin: https://xn--evil.client.example.org

# Numeric prefix — accepted
curl -sI "$API/api/config" \
  -H "Origin: https://123evil.client.example.org"
# access-control-allow-origin: https://123evil.client.example.org

CORS with credentials: true, wildcard subdomains, and DELETE listed in the preflight Access-Control-Allow-Methods. Every box on the checklist of "trivially exploitable CORS misconfiguration."

We were about to write HIGH — any subdomain can steal authenticated session data. We tried to actually exploit it first.

The Exploit That Failed

For the exploit to work in a real browser, the attacker needs to make authenticated requests from their evil subdomain. With JWT auth (Authorization header, not cookies), the attacker first has to obtain a token from the evil origin — either by signing in there, or by stealing a token from the legitimate frontend's localStorage and replaying it.

We tried signing in from the evil origin:

curl -s -X POST "$API/api/auth/signin" \
  -H "Content-Type: application/json" \
  -H "Origin: https://evil.client.example.org" \
  -d '{"email":"test@example.org","password":"Secret123"}'
{ "success": false, "message": "Unknown origin" }

CORS accepted the origin. The API rejected the sign-in. Something independent of CORS was checking.

The JWT Audience Map

Digging into jwt.config.ts surfaced this:

const audienceMap: Record<string, string> = {
  // Production
  'https://app.client.example': 'main-app',
  'https://admin.client.example':         'admin-app',
  'https://partners.client.example':  'partner-portal',
  // Staging
  'https://app.staging.client.example': 'main-app',
  'https://partners.staging.client.example':  'partner-portal',
  'https://admin.staging.client.example':         'admin-app',
  // API-internal calls
  'https://api.staging.client.example': 'api-internal',
};

export function resolveAudience(origin: string | undefined): string | null {
  if (!origin) return 'api-internal';
  return audienceMap[origin] ?? null;  // null = rejected
}

Seven explicit origins. Anything else returns null, which the calling code translates into a 401 Unknown origin. The CORS policy says "any subdomain is fine." The JWT system says "only these seven specific origins get tokens." Two access-control systems with conflicting policies — and the stricter one wins.

Testing Every Authenticated Path

We needed to know whether the audience check covers every authenticated operation or just sign-in:

# 1. Get a token from a legitimate origin.
TOKEN=$(curl -s -X POST "$API/api/auth/signin" \
  -H "Origin: https://partners.staging.client.example" \
  -d '{"email":"test@example.org","password":"Secret123"}' \
  | jq -r '.data.access_token')

# 2. Try the token from the evil origin.
curl -s "$API/api/auth/me" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Origin: https://evil.client.example.org"
# → "Unknown origin"

# 3. Try with no Origin header at all.
curl -s "$API/api/auth/me" \
  -H "Authorization: Bearer $TOKEN"
# → "Audience mismatch"
#   (token has aud=partner-portal, no origin resolves to api-internal, mismatch)

# 4. Try with a different legitimate origin.
curl -s "$API/api/auth/me" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Origin: https://app.staging.client.example"
# → "Audience mismatch"
#   (partner-portal ≠ main-app)

Every path blocked. The audience check fires on every authenticated request, not just sign-in. A token minted for partner-portal only works when sent with the partners.staging.client.example Origin header — any other origin (evil, no-origin, different legitimate origin) returns 401.

The Refresh Token Path

Token refresh has the same check:

curl -s -X POST "$API/api/auth/refresh-token" \
  -H "Origin: https://evil.client.example.org" \
  -d "{\"refresh_token\": \"$REFRESH\"}"
# → "Unknown origin"

The attacker cannot refresh a stolen token from an evil origin either.

What Is Still Vulnerable

The audience check only runs on authenticated endpoints. Public endpoints do not need it, and do not get it:

# Public config — readable from any CORS-accepted origin.
curl -s "$API/api/config" \
  -H "Origin: https://evil.client.example.org"
# 200 OK — feature flags returned

# Public catalog — same shape.
curl -s "$API/api/catalog/" \
  -H "Origin: https://evil.client.example.org"
# 200 OK

Public data is readable from any origin that the CORS policy lets through. The data itself is not sensitive — feature flags and public catalog entries — so the impact is low. The client's product owner confirmed this explicitly.

The real remaining risk is XSS on a legitimate subdomain. If an attacker achieves XSS on, say, partners.staging.client.example, they can read the JWT out of localStorage and use it from script running on that legitimate origin. Neither control fires. The audience check sees a legitimate origin and CORS sees an approved one. The two controls only stop arbitrary origins, not legitimate origins compromised via a different bug.

The Severity Downgrade

We originally drafted the CORS wildcard as HIGH. After the exploitation attempt, we downgraded it to MEDIUM, with the residual risk path documented explicitly:

Attack Scenario CORS JWT Audience Net Result
Sign in from an evil subdomain allowed blocked blocked
Use a stolen token from an evil origin allowed blocked blocked
Refresh a stolen token from evil origin allowed blocked blocked
Read public data from an evil subdomain allowed n/a succeeds (not sensitive)
Token use from XSS'd legitimate origin allowed allowed vulnerable

We still recommended the CORS fix, and it landed in the same sprint. Defense in depth means not relying on one independent control to do the work of two. Without the audience map, the CORS bug would have been trivially exploitable. With it, an attacker first needs script execution on a legitimate origin (XSS on partners.staging.client.example or similar), which is a much higher bar.

Why Two Independent Controls Worked

The CORS policy and the JWT audience map were written at different times by different people for different reasons. CORS was set up early in the project, when the team was thinking about subdomain flexibility. The audience map was added later, when multi-portal authentication came in. Neither was deliberately a "backup" for the other.

The two controls do not need to know about each other. CORS asks whether the browser is allowed to send the request; the audience check asks whether the token is valid for this origin. Different questions, enforced in different places. When one is weaker, the other holds — which is what happened here.

Two things this engagement changed for us

First: try to exploit a finding end to end before assigning severity. The path from "this check is missing" to "I can steal user data" often has unexpected obstacles, and the severity should reflect the real impact, not the impact of the broken control in isolation.

Second: unify the CORS allowlist and the JWT audience list into one source-of-truth file, two readers. When the two lists drift, defense in depth becomes defense in name only. On this engagement, we consolidated both into a single origins.config.ts at boot; the CORS middleware and the audience resolver now read from the same list.

The residual risk (XSS on a legitimate subdomain) went into the report explicitly, because a layered defense that holds against arbitrary origins still needs the reader to know what would open the attack.

On this page 10 sections
  1. The CORS Misconfiguration
  2. The Exploit That Failed
  3. The JWT Audience Map
  4. Testing Every Authenticated Path
  5. The Refresh Token Path
  6. What Is Still Vulnerable
  7. The Severity Downgrade
  8. Why Two Independent Controls Worked
  9. Two things this engagement changed for us
  10. We Could Run This Pass For Your Team
Type to search. to navigate. Enter to open. Esc to close.