Learnixo
Back to blog
AI Systemsintermediate

JWT Claims — Designing the Payload for Your Application

How to design JWT claims correctly: standard vs custom claims, claim-based authorization, reading claims in handlers, and avoiding the over-stuffed token anti-pattern.

Asma Hafeez KhanMay 16, 20264 min read
JWTClaimsASP.NET CoreAuthorization.NET
Share:𝕏

Claims Are Assertions, Not Permissions

A claim is a key-value pair that states something about the token holder: "this user's email is doctor@hospital.com", "this user has role Doctor", "this user's department is Cardiology". The server that issues the token asserts these facts. Every downstream service that validates the token trusts the assertion.

Claims are not access control lists. They are facts. Authorization policies decide what those facts allow.


Standard JWT Claims

Standard claims (registered in RFC 7519):
  sub   → Subject: who the token is about (user ID, service ID)
  iss   → Issuer: who issued the token (your auth server URL)
  aud   → Audience: who the token is intended for (your API URL)
  exp   → Expiration: Unix timestamp after which token is invalid
  iat   → Issued at: when the token was created
  nbf   → Not before: token not valid before this time
  jti   → JWT ID: unique ID for this token (used for revocation tracking)

Always include all six of these in production tokens. The jti claim enables you to revoke individual tokens if a compromise is detected.


Adding Custom Claims

C#
// TokenService.cs
private static IEnumerable<Claim> BuildClaims(User user)
{
    return
    [
        // Standard
        new Claim(JwtRegisteredClaimNames.Sub,   user.Id.ToString()),
        new Claim(JwtRegisteredClaimNames.Email, user.Email),
        new Claim(JwtRegisteredClaimNames.Jti,   Guid.NewGuid().ToString()),

        // Application-specific
        new Claim(ClaimTypes.Role,              user.Role),           // "Doctor", "Pharmacist"
        new Claim("department",                 user.Department),      // "Cardiology"
        new Claim("prescribe_schedule_ii",      user.CanPrescribeScheduleII.ToString()),
    ];
}

Reading Claims in Application Code

C#
// In a handler or endpoint — read from HttpContext.User
public sealed class GetPatientHandler
{
    private readonly IHttpContextAccessor _http;

    public async Task<Result<PatientResponse>> Handle(
        GetPatientQuery query, CancellationToken ct)
    {
        var principal = _http.HttpContext!.User;

        // Read standard claims
        var userId     = principal.FindFirstValue(JwtRegisteredClaimNames.Sub);
        var email      = principal.FindFirstValue(JwtRegisteredClaimNames.Email);

        // Read role
        var role       = principal.FindFirstValue(ClaimTypes.Role);

        // Read custom claims
        var department = principal.FindFirstValue("department");

        // Check membership
        bool isDoctor  = principal.IsInRole("Doctor");

        // ...
    }
}

Claim-Based Authorization Policies

C#
// Program.cs — define policies
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("DoctorsOnly", policy =>
        policy.RequireRole("Doctor"))

    .AddPolicy("CanPrescribeScheduleII", policy =>
        policy.RequireClaim("prescribe_schedule_ii", "True"))

    .AddPolicy("CardiologyStaff", policy =>
        policy.RequireClaim("department", "Cardiology"));
C#
// Apply to endpoints
app.MapPost("/prescriptions/schedule-ii", AddScheduleIIPrescription)
    .RequireAuthorization("CanPrescribeScheduleII");

app.MapGet("/cardiology/patients", GetCardiologyPatients)
    .RequireAuthorization("CardiologyStaff");

The Over-Stuffed Token Anti-Pattern

Anti-pattern: putting everything in the JWT

  {
    "sub": "user-123",
    "roles": ["Doctor", "Admin", "AuditViewer"],
    "permissions": ["read:patients", "write:prescriptions", "approve:orders", ...50 more...],
    "department": "Cardiology",
    "ward": "4B",
    "supervisorId": "user-456",
    "teamIds": ["team-1", "team-2", "team-3"],
    "featureFlags": { ... }
  }

Problems with this approach:

  • Every HTTP request carries a large payload (can exceed 8KB header limits)
  • Permissions encoded in the token cannot be revoked without token expiry
  • Role changes do not take effect until the token is refreshed
Better approach:
  JWT: sub, email, role (coarse-grained)
  API: load fine-grained permissions from cache on first request

  User changes role → invalidate their cached permissions
  Next request: fresh permissions loaded from DB
  JWT remains small, auth remains revocable

Injecting Claims via a Service

C#
// Infrastructure/Auth/CurrentUser.cs
public interface ICurrentUser
{
    Guid   Id         { get; }
    string Email      { get; }
    string Role       { get; }
    string Department { get; }
}

public sealed class CurrentUser : ICurrentUser
{
    private readonly IHttpContextAccessor _http;

    public CurrentUser(IHttpContextAccessor http) => _http = http;

    public Guid   Id         => Guid.Parse(Claim(JwtRegisteredClaimNames.Sub)!);
    public string Email      => Claim(JwtRegisteredClaimNames.Email)!;
    public string Role       => Claim(ClaimTypes.Role)!;
    public string Department => Claim("department")!;

    private string? Claim(string type) =>
        _http.HttpContext?.User.FindFirstValue(type);
}

// Registration
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, CurrentUser>();

Red Flag / Green Answer

Red Flag: "We put the user's permission list in the JWT so every service knows what they can do without calling the auth server."

Fine-grained permissions in JWT cannot be revoked mid-session. When a doctor leaves the hospital, their token is still valid for the remaining TTL with full permissions. If that TTL is 8 hours, there is an 8-hour window of unauthorized access.

Green Answer:

Coarse-grained roles in JWT (Doctor, Admin). Fine-grained permissions loaded from a distributed cache keyed by user ID. Revocation clears the cache entry — next request loads fresh permissions.


PRO TIP — Validate Claims in Integration Tests

C#
// Test that the right claims are in the token
[Fact]
public async Task Login_should_return_token_with_correct_claims()
{
    var response = await _client.PostAsJsonAsync("/auth/login", new
        { Email = "doctor@hospital.com", Password = "Test@123" });

    var body  = await response.Content.ReadFromJsonAsync<LoginResponse>();
    var token = new JwtSecurityTokenHandler().ReadJwtToken(body!.AccessToken);

    token.Claims.Should().Contain(c => c.Type == "department" && c.Value == "Cardiology");
    token.Claims.Should().Contain(c => c.Type == ClaimTypes.Role && c.Value == "Doctor");
}

Key Takeaway

Claims are facts about the token holder, not permissions. Keep tokens small: sub, email, coarse role. Load fine-grained permissions from cache. Use policy-based authorization to translate claims into access decisions. Never put revocable permissions in a JWT — they cannot be revoked until the token expires.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.