The Shortcut I Didn't Take

ID tokens and access tokens exist for different reasons. Here's how I learned that the hard way - and why understanding your tools matters more than ever in the age of AI.

The Problem

We needed to gate new features behind beta access. Select customers were getting early access to features we weren't ready to release publicly yet. This required role-based access control - add users to a beta-tester group, include that role as a claim in our access tokens, and check for it on specific endpoints.

Simple enough in theory. Then we hit a wall.

Configuring custom claims in our authorization server - Ping - turned out to be more complex than anyone expected. The path of least resistance emerged quickly: just add the beta-tester role to the ID token instead. It was already a signed JWT. It already had user info. Our backend could validate it. Ship it.

Something felt wrong. My gut said there was a better way. So instead of taking the shortcut, I went deep on Ping's documentation. I learned how the system was actually designed to work.

What I Found

ID tokens and access tokens exist for different reasons. ID tokens carry identity information to client applications - who you are. Access tokens carry authorization information to APIs - what you're allowed to do. They have different audiences, different purposes, different security boundaries.

Passing ID tokens to our backend APIs would have worked, technically. But it violated the architecture these protocols were designed around. We'd be trusting tokens issued for a different recipient. Every API call would need modification to send the ID token instead of the access token we were already sending. And we'd be building on a foundation that would confuse every engineer who touched this code after us.

The shortcut would have shipped faster. It also would have haunted us.

The Actual Solution

Here's what I figured out: Ping lets you create custom resources. On that resource, you configure the access token itself - including the audience and any custom claims you want attached. Then you set up a scope to request access to that resource.

So I created a custom resource with our API's audience. On that resource, I configured the access token to include our role claims. Then I set up a scope that grants access to it.

Now when our frontend requests this scope during login, Ping issues the access token configured on that resource - with the correct audience and the beta-tester role attached. We pass that token to our backend, and everything works as intended.

The code change was minimal. We just added the new scope to our Ping authentication request. The real work was understanding Ping well enough to configure the resource correctly.

But I wasn't done. I wanted a deployment that couldn't fail.

The Rollback Plan

I set up each of our APIs with backwards compatibility - they accepted both the old default access token audience and our new custom access token audience. Then I feature-flagged the scope string in our Ping authentication request.

This meant we could deploy with zero risk. If anything went wrong - anything at all - we flip the feature flag. The frontend requests the old scope. Ping returns the default access token. The backends accept it because they're backwards compatible. Users never notice.

We shipped it. Nothing went wrong. But if it had, we were ready.

What I Learned

This was a significant win for me as an engineer. Not because the code was complex - it wasn't. Because I trusted my intuition that there was a right way to do this, and I did the work to find it.

Understanding the systems you depend on is a form of sovereignty. When you know how something actually works - not just how to make it do what you want - you make better decisions. You see shortcuts for what they are. You build things that last.

The team that comes after you inherits your choices. I wanted to leave them something solid.


The Technical Details

If you're implementing something similar, here's what matters.

Token purposes: ID tokens authenticate users to your client application. Access tokens authorize requests to your APIs. Don't cross the streams.

The flow:

sequenceDiagram
    participant User
    participant Client as Client App
    participant Auth as Auth Server
    participant API as Resource API

    User->>Client: 1. Initiate login
    Client->>Auth: 2. Authorization request<br/>(scope: openid, custom-api-scope)
    Auth->>User: 3. Login prompt
    User->>Auth: 4. Credentials
    Auth->>Client: 5. ID Token + Custom Access Token
    Note over Client: ID Token stays here<br/>Access Token goes to APIs
    Client->>API: 6. API request with Access Token
    API->>API: 7. Validate Access Token<br/>(check audience, roles, signature)
    API->>Client: 8. Protected resource

Backwards compatibility in .NET:

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://your-auth-server.com";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = true,
            ValidAudiences = new[]
            {
                "default-api-audience",
                "custom-api-audience"
            },
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true
        };
    });

Deploy this before you change token issuance. Your APIs accept both. Zero-downtime migration.

RBAC with feature flags:

public class FeatureAuthorizationService(
    IFeatureManager featureManager) : IFeatureAuthorizationService
{
    public async Task<bool> HasFeatureAccessAsync(
        ClaimsPrincipal user, 
        string requiredRole, 
        string featureName)
    {
        var userRoles = user.FindAll("roles").Select(c => c.Value); 
        var hasRoleAccess = userRoles.Contains(requiredRole);
        var publicRelease = await featureManager.IsEnabledAsync($"{featureName}_Public");
        var betaEnabled = await featureManager.IsEnabledAsync($"{featureName}_Beta");   

        return publicRelease || (hasRoleAccess && betaEnabled);
    }
}

Beta testers get access when the beta flag is on. Flip the public flag and everyone gets it. No code changes for rollout.


On AI and Understanding

I used AI to help write this post. I'm not going to pretend otherwise.

But here's the thing: AI could have given me a working solution to the original problem too. I could have prompted my way to "just pass the ID token" and shipped it. The code would have compiled. The tests would have passed. And I would have learned nothing.

AI is a tool. A powerful one. But it can't tell you when something feels wrong. It doesn't know your system's history or the people who'll maintain it after you. It optimizes for the answer you asked for, not the answer you actually need.

The engineers who thrive with AI won't be the ones who outsource their thinking. They'll be the ones who use it to go deeper - to understand faster, to explore more options, to validate their intuition. The gut check still has to come from you.

I'm glad I trusted mine on this one.