Eliminating Database Lookups with Auth0 Custom Claims

How to enrich JWT tokens with user data during login, eliminating per-request database lookups while keeping your domain logic decoupled from your identity provider.

Most JWT authentication implementations have a performance problem: they look up user data from the database on every authenticated request. The token proves who you are, but the application still needs to fetch your profile, preferences, or display name before it can do anything useful.

This pattern adds latency to every request and creates unnecessary database load. There's a better way.

The Problem: Per-Request Database Lookups

A typical JWT authentication flow looks like this:

  1. User logs in via Auth0 (or another identity provider)
  2. Auth0 returns a JWT token with the user's identity (usually a subject ID like auth0|abc123)
  3. Client includes token in subsequent API requests
  4. Server validates the token
  5. Server looks up user data from the database using the identity provider's subject ID
  6. Server processes the request with user context

Step 5 happens on every authenticated request. If you need the user's display name, preferences, or internal user ID, you're hitting the database every time. Even with caching, you're adding latency and complexity.

The Solution: Custom Claims in the Token

Auth0 (and most modern identity providers) support custom claims. You can add arbitrary data to the JWT token during login, before it reaches the client. This means user data lives in the token itself, not in your database.

The pattern works like this:

  1. User logs in via Auth0
  2. Auth0 Action calls your API to resolve the user (lookup or create)
  3. Your API returns the data you want in the token (displayName, and optionally other data)
  4. Auth0 adds this data as custom claims before returning the token
  5. Client receives token with user data baked in
  6. Server validates token and extracts user data from claims
  7. No database lookup needed

This eliminates step 5 from the original flow. The token itself carries everything you need.

Security note: JWT token bodies are base64-encoded, not encrypted. Anyone with the token can decode and read the claims. Only include data in custom claims that you're comfortable being readable. Public-facing data like display names is ideal. Internal identifiers require more consideration (more on this later).

How It Works: The Auth0 Action Hook

Auth0 Actions are JavaScript functions that run during the authentication pipeline. A Post-Login Action runs after the user authenticates but before the token is issued. This is where you enrich the token.

Here's the Auth0 Action code:

exports.onExecutePostLogin = async (event, api) => {
  // Only run for your application
  if (event.client.client_id !== 'your-client-id') {
    return;
  }

  const auth0Subject = event.user.user_id;

  // Call your API to resolve the user
  const response = await fetch('https://api.yourdomain.com/api/auth/hooks', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${event.secrets.API_SECRET}`
    },
    body: JSON.stringify({ auth0Subject })
  });

  if (!response.ok) {
    api.access.deny('User resolution failed');
    return;
  }

  const { displayName } = await response.json();

  // Add custom claims to the access token
  api.accessToken.setCustomClaim('yourdomain/displayName', displayName);

  // Optional: include internal user ID only if comfortable with it being readable
  // const { userId } = await response.json();
  // api.accessToken.setCustomClaim('yourdomain/userId', userId);
};

The Action sends the Auth0 subject to your API. Your API looks up the user by that subject. If the user exists, return their data. If not, create the user first, then return their data. The Action adds this data to the token as custom claims.

Custom claim names must be namespaced (like yourdomain/userId) to avoid collisions with standard claims.

Server-Side Implementation

Your API needs two pieces: the hook endpoint that Auth0 calls, and the JWT validation that reads the custom claims.

The Hook Endpoint (C#/ASP.NET)

[HttpPost("hooks")]
public async Task<IActionResult> ResolveUser(
    [FromBody] AuthHookRequestDto request)
{
    // Validate the request is from Auth0
    var authHeader = Request.Headers["Authorization"].ToString();
    if (!IsValidAuthHookRequest(authHeader))
    {
        return Unauthorized();
    }

    // Look up user by Auth0 subject
    var user = await _userRepository.GetByAuth0SubjectAsync(
        request.Auth0Subject);

    // Create user if they don't exist
    if (user == null)
    {
        user = new UserDocument
        {
            Id = Guid.NewGuid().ToString(),
            Auth0Subject = request.Auth0Subject,
            DisplayName = ExtractNameFromAuth0(request),
            CreatedAt = DateTime.UtcNow
        };
        await _userRepository.CreateAsync(user);
    }

    return Ok(new AuthHookResponseDto
    {
        DisplayName = user.DisplayName
        // Include userId only if your API already exposes it publicly
        // and you're comfortable with it being readable in the token
    });
}

This endpoint is secured with a shared secret between Auth0 and your API. The secret lives in Auth0 Secrets (not in your Action code) and in your API configuration (Azure Key Vault, environment variables, etc.).

JWT Validation with Custom Claims (C#/ASP.NET)

Configure your ASP.NET API to validate Auth0 tokens and preserve custom claim names:

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = $"https://{auth0Domain}/";
        options.Audience = auth0Audience;
        options.MapInboundClaims = false; // Preserve custom claim names
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true
        };
    });

The critical line is MapInboundClaims = false. By default, ASP.NET maps standard JWT claim names to .NET claim types, which mangles custom claim names. Setting this to false preserves the names you set in Auth0.

Now you can read claims in your controllers:

[HttpPost("posts/{postId}/comments")]
[Authorize]
public async Task<IActionResult> CreateComment(
    string postId,
    [FromBody] CreateCommentDto request)
{
    var displayName = User.FindFirst("yourdomain/displayName")?.Value;
    var auth0Subject = User.FindFirst("sub")?.Value;

    if (displayName == null || auth0Subject == null)
    {
        return Unauthorized("Missing user claims");
    }

    // Use displayName for attribution without a database lookup
    var comment = new Comment
    {
        PostId = postId,
        AuthorSubject = auth0Subject,
        AuthorName = displayName,
        Content = request.Content,
        CreatedAt = DateTime.UtcNow
    };

    await _commentRepository.CreateAsync(comment);
    return CreatedAtAction(nameof(GetComment), new { id = comment.Id }, comment);
}

No database call to fetch the author's display name. It's already in the token, ready to be denormalized into the comment for display.

Decoupling from the Identity Provider

This pattern has a subtle but important architectural benefit: it decouples your domain from your identity provider.

Your application uses internal user IDs (userId), not Auth0 subjects (auth0|abc123). Your database stores your own user records with your own IDs. The Auth0 subject is just a lookup key used once during login.

If you later migrate from Auth0 to another provider (or add a second provider), your domain logic doesn't change. You just update the hook endpoint to handle the new provider's subjects. The rest of your application still works with userId and displayName from the token.

Alternative Approach: Push Model

The pattern described above is a pull model. Auth0 pulls user data from your API on every login. There's an alternative: the push model, where your API pushes user data to Auth0 using the Management API.

How the Push Model Works

  1. User updates their display name in your application
  2. Your API updates the user record in your database
  3. Your API calls the Auth0 Management API to update user_metadata or app_metadata
  4. Auth0 stores this data
  5. On the next login, the Auth0 Action reads the metadata and adds it to custom claims (or Auth0 includes it automatically)

This approach has some advantages:

  • Data persists in Auth0 between logins
  • You can update user metadata without requiring a new login
  • No need for an Auth0-to-API callback endpoint

But it introduces significant complexity:

Rate limits: The Auth0 Management API has rate limits that vary by plan. At scale, pushing metadata on every user update can hit these limits.

Two sources of truth: Your database and Auth0 both store user data. They can drift out of sync if one update fails. Do you roll back the database write if the Auth0 call fails? Or accept eventual consistency?

M2M credentials: You need a Machine-to-Machine application in Auth0 with Management API permissions. This means more secrets to manage and rotate.

Coupling: Your domain logic now depends on successfully pushing to Auth0. If the Management API is unavailable or rate-limited, your user updates fail (or you queue them, adding more complexity).

Cost: Management API calls may have usage costs depending on your Auth0 plan.

Why This Article Focuses on the Pull Model

The pull model is architecturally simpler. Your database remains the single source of truth. Auth0 just reads from it on login. There's no two-phase update, no eventual consistency concerns, and no dependency on the Management API.

The push model makes sense if you need to update token data between logins without forcing a re-authentication. But for stable user context like internal user IDs and display names, the pull model is cleaner and easier to reason about.

If you're building a new system, start with the pull model. You can always add push updates later for specific use cases if needed.

Security Considerations: What Belongs in Tokens

Remember: JWT token bodies are base64-encoded, not encrypted. Anyone with the token can decode it and read all the claims. This has important implications for what you should include.

Safe for tokens (public-facing data):

  • Display names
  • Email addresses (if already public in your app)
  • User preferences (theme, language)
  • Non-sensitive profile data

Questionable for tokens (internal identifiers):

  • Internal user IDs, especially if:
    • They're sequential integers (reveals user count, growth rate)
    • They're used in URLs or API paths (makes them predictable)
    • Your API doesn't already expose them publicly
  • Consider using the Auth0 subject for authorization instead, and only looking up your internal ID when you need to write data

Never in tokens:

  • Account balances, credits, subscription status
  • Permissions that change frequently
  • API keys, secrets, or credentials
  • Any financial or security-critical data
  • Personal information you wouldn't expose in your API

General rule: Only include data in custom claims that you'd be comfortable returning in an unauthenticated API response. If you wouldn't show it publicly, it probably shouldn't be in the token.

For the displayName use case (comment attribution, post authorship, etc.), this is a clear win. The name is meant to be public. Including it in the token eliminates database lookups without any security concern.

Trade-Offs and Considerations

This pattern works well when:

  • You need public user context on most authenticated requests
  • The data is small and rarely changes (display name, preferences)
  • You control the API that Auth0 calls (it's your backend, not a third-party service)

It's less appropriate when:

  • User data changes frequently (the token won't reflect changes until the next login)
  • You need large amounts of user data (keep tokens small)
  • Your identity provider doesn't support custom claims or actions (not all do)

For frequently changing or critical data, fetch it from the database on the requests that need it. The token should only carry stable, public context.

Token Refresh Considerations

JWTs have an expiration time (usually 1-24 hours). If your user changes their display name, the token won't reflect it until they log in again (or refresh their token, depending on your configuration).

This is usually fine. Display names don't change often. If they do, you can force a token refresh by logging the user out and back in, or by using Auth0's token refresh flow to issue a new token with updated claims.

For critical data that must be perfectly consistent, stick with database lookups. Custom claims are for convenience and performance, not for data that requires strong consistency guarantees.

Conclusion

Custom claims eliminate a common performance bottleneck in JWT authentication: the per-request database lookup for user context. By enriching the token during login with an Auth0 Action, you can carry public user data in the token itself.

Display names are an ideal use case. If you're building a system with comments, posts, or any content that shows user attribution, putting the display name in the token means you never need to look it up. The data is already there, ready to be denormalized into your content records.

This pattern reduces latency, simplifies your API code, and decouples your domain from your identity provider. The trade-off is that token data can become stale between logins, which is acceptable for stable, public user context.

If you're building an API with Auth0 (or another provider that supports custom claims), this pattern is worth implementing early. It will scale better than looking up user data on every request.