Access Tokens, ID Tokens, and RBAC: A .NET Implementation Guide

ID tokens contain identity claims about authenticated users. Access tokens provide proof of authorization to access protected resources. A real implementation story, .NET examples, and advanced security patterns.

Introduction

OAuth 2.0 is an authorization framework that lets applications access protected resources on behalf of users. OpenID Connect is an identity layer built on top of OAuth 2.0 that standardizes how user authentication information is shared between systems.

These protocols use several token types. This post focuses on three: ID tokens, which contain claims about the authenticated user and always use JWT format; access tokens, which provide credentials for API access and can be opaque strings or JWTs; and refresh tokens, which obtain new access tokens without requiring re-authentication.

Mixing up these tokens creates security issues and architectural problems.

A Real-World Example

My team needed to implement beta testing for new features. This required role-based access control - add users to a beta-tester group, include that role as a claim in our tokens, and check for it on specific endpoints.

Configuring custom claims in our authorization server (Ping) turned out to be more complex than expected. The quick workaround that surfaced first was to add the beta-tester role to the ID token instead and pass that to our backend APIs.

The approach seemed reasonable on the surface - the ID token was already a signed JWT with user information that our backend could validate. But it created several problems:

  • Wrong audience. ID tokens target the frontend application, not backend APIs. Our APIs would be trusting tokens issued for a different recipient.
  • Architecture violation. It breaks the intended separation of concerns where ID tokens carry identity information to clients and access tokens carry authorization information to APIs.
  • Frontend complexity. Every API call would need modification to send ID tokens instead of the default access tokens we were already sending.

The correct solution was to configure Ping properly. Ping lets you define custom resources; on each resource, you configure the access token that will be issued - including its audience and any custom claims. Then you create a scope that grants access to that resource.

I created a custom resource with our API's audience, configured the access token to include our role claims, and set up a scope to request it. The frontend added the new scope to its authentication request. Ping issued an access token with the correct audience and the beta-tester claim attached. Backend APIs received the access token (not the ID token) with the role information they needed.

The Intended 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

Token Types

ID Tokens

ID tokens are always JWTs because the OpenID Connect specification requires this format. The JWT structure provides standardized claims and cryptographic verification that clients need for authentication flows.

ID tokens contain user identity information - name, email, roles, and other profile data - along with standard claims like iss (issuer), aud (audience), and exp (expiration). Since JWTs are base64-encoded rather than encrypted, ID token contents are readable by anyone, so they shouldn't contain sensitive information.

You request ID tokens by including the openid scope in your authorization request. This typically happens when your client application redirects users to the authorization server for login. They're usually short-lived, often expiring within an hour.

Access Tokens

Access tokens authorize API requests. When a user authenticates, the authorization server issues an access token that defines what resources the application can access on the user's behalf.

Access tokens can be opaque strings or JWTs, depending on authorization server configuration. Unlike ID tokens, they target APIs rather than client applications.

Access tokens contain authorization information - scopes for broad permissions and claims for specific access decisions. In RBAC systems, these claims might include user roles like admin or beta-tester that APIs use for authorization decisions.

They're short-lived for security reasons. If an attacker steals an access token, they can make API requests with the victim's permissions until the token expires. This makes proper token storage critical, especially in browser-based applications.

Refresh Tokens

Refresh tokens obtain new access tokens without requiring user re-authentication. They typically have longer lifespans than access tokens and are used to maintain sessions, but they must be stored securely and rotated regularly to prevent abuse.

.NET Examples

Multiple Audience Configuration

When transitioning to new access token configurations, your API must accept tokens from both the old and new sources. Configure JWT bearer authentication to validate multiple audiences:

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

The ValidAudiences array accepts tokens issued for either audience. Deploy this configuration before changing token issuance on the authorization server. This prevents API rejections during the transition and enables zero-downtime deployment.

Pair this with a feature-flagged scope on the client. When the flag is on, the frontend requests the new scope and receives the custom access token. When it's off, the frontend requests the legacy scope and receives the default access token. Either way, your backend accepts it because it's configured for both audiences. If anything goes wrong, flip the feature flag - users never notice.

RBAC with Access Token Claims

Extract user roles from access tokens to implement RBAC authorization decisions. This example shows claims extraction for feature-level authorization that enables early access for specific user groups - combining role-based checks with feature flags to control access during development phases.

public interface IFeatureAuthorizationService
{
    Task<bool> HasFeatureAccessAsync(
        ClaimsPrincipal user, 
        string requiredRole, 
        string featureName);
}

public class FeatureAuthorizationService(
    IFeatureManager featureManager) : IFeatureAuthorizationService
{
    public async Task<bool> HasFeatureAccessAsync(
        ClaimsPrincipal user, 
        string requiredRole, 
        string featureName)
    {
        // "roles" claim name may vary by auth provider
        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);
    }
}

// Register service in dependency injection container
services.AddScoped<IFeatureAuthorizationService, FeatureAuthorizationService>();

// Configure endpoints with role-based authorization
app.MapGet("/api/new-dashboard", async (
    HttpContext context, 
    IFeatureAuthorizationService authService) =>
{
    if (await authService.HasFeatureAccessAsync(
        context.User, 
        "new-dashboard-beta", 
        "NewDashboard"))
    {
        return Results.Ok(new { message = "New dashboard enabled" });
    }
    
    return Results.NotFound();
})
.RequireAuthorization();

The service extracts role claims from access tokens and combines them with feature flags for flexible access control. Each endpoint checks for feature-specific roles rather than generic beta access. The boolean logic handles both beta testing and public rollout without requiring code changes when transitioning between phases.

This service approach also enables straightforward unit testing - mock IFeatureManager, test different combinations of user roles and feature flags, and you don't need to spin up the full ASP.NET authorization pipeline.

Advanced Security Patterns

Secure Token Storage in Browsers

Browser storage like localStorage and sessionStorage is vulnerable to XSS attacks that can steal tokens. Store access tokens in httpOnly cookies instead, which prevent JavaScript access to tokens.

For SPAs, consider the Backend-for-Frontend (BFF) pattern, where tokens never reach the browser. Advanced BFF implementations like Phantom Tokens use opaque tokens in the browser while API gateways exchange them for JWTs with full claims. Split Token patterns send only JWT signatures to clients while gateways reconstruct complete tokens from cached components.

Client-Bound Tokens (DPoP)

Standard bearer tokens work for anyone who possesses them. DPoP (Demonstration of Proof-of-Possession) binds access tokens to a public key during issuance. Clients must prove possession of the corresponding private key when using the access token at the resource server by creating a DPoP proof JWT that demonstrates possession of the private key and includes a hash of the access token. Stolen tokens become useless without the matching private key.

Certificate-Bound Access Tokens (mTLS)

Mutual TLS binds access tokens to client certificates used during authentication. The authorization server includes the certificate thumbprint in the access token after successful mTLS client authentication. When accessing APIs, clients must establish an mTLS session using the same certificate. The API compares the certificate thumbprint from the TLS session with the thumbprint in the token. Stolen tokens are useless without the corresponding private key and certificate.

Conclusion

ID tokens carry identity information to client applications. Access tokens carry authorization to APIs. Using each for its intended purpose prevents architectural problems and security vulnerabilities.

The patterns shown here - multiple audience validation for zero-downtime migrations, claims-based RBAC for flexible feature access, and advanced techniques like DPoP and mTLS for stronger token binding - are tools worth reaching for before you hit a wall. The shortcut that feels easier in the moment is usually the one that compounds into problems for every engineer who touches the code after you.