How to Debug a Broken Web App: A Noob’s Guide to Token Hell

Problem: The settings page on a production web app is broken. Users see a flash of the page, then get kicked back to the home screen. Console shows a cryptic JSON.parse error. Someone says “looks like a token issue.”

If you’re new to debugging web apps, this is terrifying. You don’t know where to start. Is it the frontend? The backend? The database? Authentication?

Here’s exactly how I found and fixed the bug — step by step, with real commands and real code. No magic. Just methodical debugging that anyone can learn.

Step 0: Trust Nothing. Test Everything.

Before touching any code, verify what’s actually happening in production. Browser devtools tell you the symptom, but not the cause.

Open DevTools → Network tab. Navigate to the broken page. Watch the API calls. The settings page tries to fetch /api/account/users/me. Instead of JSON, it gets… HTML. An entire login page in HTML. The Angular app tries JSON.parse() on HTML → crash.

This tells us: the API is returning a login page instead of user data. Either the user isn’t authenticated, or the server thinks they aren’t.

Step 1: Test the API Directly with curl

Browser requests are noisy — cookies, redirects, CORS. Strip all that away. Test the API endpoint directly:

# Step 1a: Log in and get a token
curl -X POST https://example.com/connect/token \
  -d "grant_type=password&username=testuser&password=hunter2&client_id=myapp&scope=openid profile api"

# Response: {"access_token": "eyJhbGciOi...", "refresh_token": "..."}

# Step 1b: Call the broken endpoint with the token
curl https://example.com/api/account/users/me \
  -H "Authorization: Bearer eyJhbGciOi..." \
  -v  # verbose — shows HTTP status and headers

Result: HTTP 302 redirect to /Account/Login. Not 401 (unauthorized). Not 200 (success). A redirect.

This is the first clue. A 302 redirect to the login page means the server thinks you need to authenticate again — even though you just got a valid token. Something is rejecting the token on the API endpoint.

Step 2: Decode the JWT — Is the Token Even Valid?

JWT tokens are just base64-encoded JSON. Copy the token, split it at the dots, decode the middle part:

# The token is three base64 strings separated by dots:
# header.payload.signature

# Decode the payload (middle part):
echo "eyJhbGciOi..." | cut -d'.' -f2 | base64 -d | python -m json.tool

What to check:

  • exp — Is the token expired? Convert the Unix timestamp to a human date.
  • iss — Does the issuer match the server you’re calling?
  • aud — Is the audience claim present? (Spoiler: it wasn’t.)
  • sub — The user ID. Does this match an actual user in the database?
  • permission — Does the token have the right permissions?

My token was: valid (60 min remaining), correct issuer, correct scopes, correct permissions, user exists. Yet the API still returned 302. The token is fine. The problem is on the server.

Step 3: Follow the 302 Trail — Where Is It Coming From?

The 302 goes to /Account/Login. That’s the default login redirect for ASP.NET Core’s cookie authentication. But we’re using JWT Bearer tokens, not cookies. Why is cookie auth involved?

In the controller code, when authorization fails, the app returns a ChallengeResult(). This triggers all authentication handlers to issue a challenge. The cookie handler responds with a 302 redirect to the login page. The fix: change ChallengeResult() to Forbid() so API endpoints return 403 Forbidden instead of a redirect.

But that just changes the symptom from 302 to 403. The real question is: why is authorization failing when the user has a valid token with correct permissions?

Step 4: Add Diagnostic Logging — What Claims Does the Server Actually See?

This is the most important step. Never guess why code fails. Make it tell you.

Add temporary logging right before the authorization check:

// Before the AuthorizeAsync call, log every claim
Console.WriteLine($"Claims count: {User.Claims.Count()}");
foreach (var c in User.Claims)
    Console.WriteLine($"  {c.Type} = {c.Value}");

Console.WriteLine($"FindFirst('sub'): {User.FindFirst("sub")?.Value ?? "NOT FOUND"}");

var result = await _authorizationService.AuthorizeAsync(User, id, requirement);
Console.WriteLine($"AuthorizeAsync: Succeeded={result.Succeeded}");

The log output revealed everything:

Claims count: 32
  iss = https://example.com
  scope = openid
  scope = api
  http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier = abc-123-user-guid
  email = user@example.com
  permission = users.view
  permission = users.manage
  name = testuser
  ...

FindFirst('sub'): NOT FOUND   ← THIS IS THE BUG

AuthorizeAsync: Succeeded=False

There are 32 claims, including the user ID. But where is the “sub” claim?

Look again. The user ID is there — but it’s stored under the claim type http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier. Not "sub".

The JWT token has "sub": "abc-123-user-guid". But by the time the .NET middleware processes it, the claim has been remapped from the short JWT name "sub" to the long XML-SOAP name "http://schemas.xmlsoap.org/.../nameidentifier". The authorization code looks for "sub" → finds nothing → user ID is null → authorization fails → 403.

Step 5: The Root Cause — Claim Type Remapping

.NET’s JWT middleware has a feature called inbound claim type mapping. It automatically renames JWT claim types to their long-form XML equivalents for backward compatibility with older .NET code. This is enabled by default.

The code had already called JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() to clear the mapping dictionary. But this only affects the older JwtSecurityTokenHandler. In .NET 8+, the default handler changed to JsonWebTokenHandler, which ignores the cleared map and remaps claims anyway.

Additionally, MapInboundClaims was set to true (the default). When true, the handler remaps known claim types. When false, claims keep their original JWT names.

The fix — one line:

// In Startup.cs, AddJwtBearer options:
options.MapInboundClaims = false;  // Keep 'sub' as 'sub'

That’s it. After this change, FindFirst("sub") found the user ID, authorization passed, the API returned 200 OK with user data, and the settings page loaded correctly.

Bonus: Two Other Bugs Found Along the Way

Methodical debugging often finds more than the bug you’re looking for:

  1. ValidateAudience = true but no aud claim. The JWT middleware was configured to validate the audience claim, but the JWT tokens issued by IdentityServer didn’t include one. Fix: ValidateAudience = false (scope enforcement happens at token issuance, not validation).
  2. Null reference on missing user. The preferences endpoint called appUser.Configuration without checking if appUser was null (user not found in DB). Fix: add a null check that returns 404.

The Debugging Mindset

Here’s what I want you to take away from this:

  • Never guess. Every time you say “maybe it’s X” without evidence, you waste time. Test it.
  • Strip away layers. Browser → curl. curl gets you the raw HTTP response without JavaScript, CORS, or redirect-following getting in the way.
  • Add logging, not breakpoints. Logs persist after the request. You can grep them. You can share them. Breakpoints are temporary and limited to your machine.
  • Follow the data. The JWT had the right claims. The question wasn’t “are the claims there?” but “where did they go?” Tracing the claim from JWT → middleware → ClaimsPrincipal revealed the remapping.
  • Know your framework’s defaults. MapInboundClaims = true by default. ValidateAudience = true by default. Defaults bite you when you don’t know about them.
  • Test locally first. Docker makes this easy. Spin up the stack, test with curl, add logging, iterate fast. Don’t debug on production.

Quick Reference: curl Commands for API Debugging

# Get a token
curl -X POST https://example.com/connect/token \
  -d "grant_type=password&username=USER&password=PASS&client_id=CLIENT&scope=SCOPES" \
  -H "Content-Type: application/x-www-form-urlencoded"

# Call API (show headers, don't follow redirects)
curl -v https://example.com/api/endpoint \
  -H "Authorization: Bearer TOKEN" \
  -L  # remove this to NOT follow redirects

# Decode JWT payload (bash)
echo "JWT_TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | python3 -m json.tool

# Check if site is alive
curl -s -o /dev/null -w "%{http_code}" https://example.com/health

Now go debug something.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *