ProblemDetails Error Handling: RFC 7807 in ASP.NET Core

TL;DR RFC 7807 ProblemDetails for API errors: IExceptionHandler setup, custom error mapping, validation responses, and sensitive data handling.

Common Questions This Answers

  • What is the standard format for API error responses?
  • How do I implement global exception handling in ASP.NET Core?
  • What's the difference between ProblemDetails and ValidationProblemDetails?
  • How do I customize error responses without exposing sensitive information?
  • When should I use IExceptionHandler vs middleware?

Definition

ProblemDetails is an RFC 7807-compliant response format for HTTP API errors. Instead of returning inconsistent error shapes across your API, ProblemDetails provides a standardized structure that clients can parse predictably. ASP.NET Core has built-in support for generating ProblemDetails responses.

Terms Used

  • RFC 7807: Internet standard defining Problem Details for HTTP APIs
  • ProblemDetails: The .NET class implementing RFC 7807
  • ValidationProblemDetails: Extended ProblemDetails for model validation errors
  • IExceptionHandler: Interface for handling exceptions (.NET 8+)
  • IProblemDetailsService: Service for generating ProblemDetails responses

Reader Contract

After reading this article, you will:

  1. Understand the RFC 7807 ProblemDetails format
  2. Configure global exception handling with IExceptionHandler
  3. Customize error responses for different exception types
  4. Handle validation errors with ValidationProblemDetails
  5. Prevent sensitive data leakage in error responses

Prerequisites: Basic ASP.NET Core Web API knowledge. See Minimal APIs vs Controllers for API architecture decisions.

Time to implement: 30 minutes for basic setup, 1-2 hours for comprehensive error handling.

Quick Start (10 Minutes)

Enable ProblemDetails for your API:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddProblemDetails();

var app = builder.Build();

app.UseExceptionHandler();
app.UseStatusCodePages();

app.MapControllers();
app.Run();

Now unhandled exceptions and error status codes return RFC 7807 responses:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500
}

RFC 7807 ProblemDetails Format

RFC 7807 defines five standard members:

Member Type Purpose
type URI Identifies the problem category. Defaults to about:blank
title string Brief, human-readable summary. Should not change between occurrences
status number HTTP status code
detail string Occurrence-specific explanation
instance URI Identifies this specific occurrence

Example Response

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "https://example.com/problems/insufficient-funds",
  "title": "Insufficient funds",
  "status": 403,
  "detail": "Your account balance is 30, but the transaction requires 50.",
  "instance": "/transactions/abc123"
}

Extension Members

You can add custom properties beyond the standard five:

{
  "type": "https://example.com/problems/insufficient-funds",
  "title": "Insufficient funds",
  "status": 403,
  "detail": "Your account balance is 30, but the transaction requires 50.",
  "balance": 30,
  "required": 50,
  "accountId": "12345"
}

Clients must ignore extension members they don't recognize.

Built-in ProblemDetails Support

The ProblemDetails Class

ASP.NET Core's ProblemDetails class maps directly to RFC 7807:

public class ProblemDetails
{
    public string? Type { get; set; }
    public string? Title { get; set; }
    public int? Status { get; set; }
    public string? Detail { get; set; }
    public string? Instance { get; set; }
    public IDictionary<string, object?> Extensions { get; }
}

Returning ProblemDetails from Controllers

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult GetOrder(int id)
    {
        var order = FindOrder(id);

        if (order is null)
        {
            return Problem(
                title: "Order not found",
                detail: $"No order exists with ID {id}",
                statusCode: StatusCodes.Status404NotFound);
        }

        return Ok(order);
    }
}

Returning ProblemDetails from Minimal APIs

app.MapGet("/orders/{id}", (int id) =>
{
    var order = FindOrder(id);

    return order is null
        ? Results.Problem(
            title: "Order not found",
            detail: $"No order exists with ID {id}",
            statusCode: StatusCodes.Status404NotFound)
        : Results.Ok(order);
});

IExceptionHandler (.NET 8+)

IExceptionHandler provides a clean way to handle exceptions globally. Implement the interface and register it:

public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        logger.LogError(exception, "Unhandled exception occurred");

        var problemDetails = new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title = "An unexpected error occurred",
            Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1"
        };

        httpContext.Response.StatusCode = problemDetails.Status.Value;

        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);

        return true;  // Exception handled
    }
}

Register the handler:

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

var app = builder.Build();

app.UseExceptionHandler();  // Required to activate IExceptionHandler

Multiple Exception Handlers

Register multiple handlers for different exception types. Handlers run in registration order:

builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();  // Catch-all last

Each handler returns true to stop processing or false to continue to the next handler:

public class NotFoundExceptionHandler : IExceptionHandler
{
    public ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is not NotFoundException notFound)
        {
            return ValueTask.FromResult(false);  // Not handled, try next
        }

        // Handle NotFoundException...
        return ValueTask.FromResult(true);
    }
}

Global Exception Handling Patterns

Mapping Exception Types to Status Codes

Create a structured approach for consistent error responses:

public class GlobalExceptionHandler(
    ILogger<GlobalExceptionHandler> logger,
    IHostEnvironment environment) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        logger.LogError(exception, "Exception: {Message}", exception.Message);

        var (statusCode, title) = exception switch
        {
            ArgumentException => (StatusCodes.Status400BadRequest, "Invalid argument"),
            KeyNotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
            UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "Unauthorized"),
            InvalidOperationException => (StatusCodes.Status409Conflict, "Operation not valid"),
            _ => (StatusCodes.Status500InternalServerError, "An error occurred")
        };

        var problemDetails = new ProblemDetails
        {
            Status = statusCode,
            Title = title,
            Instance = httpContext.Request.Path
        };

        // Only include detail in development
        if (environment.IsDevelopment())
        {
            problemDetails.Detail = exception.Message;
        }

        httpContext.Response.StatusCode = statusCode;
        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);

        return true;
    }
}

Custom Exception Types

Define domain exceptions that map to specific error responses:

public abstract class DomainException : Exception
{
    public abstract int StatusCode { get; }
    public abstract string ErrorCode { get; }

    protected DomainException(string message) : base(message) { }
}

public class EntityNotFoundException : DomainException
{
    public override int StatusCode => StatusCodes.Status404NotFound;
    public override string ErrorCode => "ENTITY_NOT_FOUND";

    public string EntityType { get; }
    public string EntityId { get; }

    public EntityNotFoundException(string entityType, string entityId)
        : base($"{entityType} with ID '{entityId}' was not found")
    {
        EntityType = entityType;
        EntityId = entityId;
    }
}

public class BusinessRuleException : DomainException
{
    public override int StatusCode => StatusCodes.Status422UnprocessableEntity;
    public override string ErrorCode => "BUSINESS_RULE_VIOLATION";

    public BusinessRuleException(string message) : base(message) { }
}

Handle domain exceptions with a dedicated handler:

public class DomainExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is not DomainException domainException)
        {
            return false;
        }

        var problemDetails = new ProblemDetails
        {
            Status = domainException.StatusCode,
            Title = domainException.ErrorCode,
            Detail = domainException.Message,
            Instance = httpContext.Request.Path
        };

        // Add domain-specific extensions
        if (domainException is EntityNotFoundException notFound)
        {
            problemDetails.Extensions["entityType"] = notFound.EntityType;
            problemDetails.Extensions["entityId"] = notFound.EntityId;
        }

        httpContext.Response.StatusCode = domainException.StatusCode;
        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);

        return true;
    }
}

Validation Error Responses

ValidationProblemDetails

For validation errors, use ValidationProblemDetails which includes an Errors dictionary:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Email": ["The Email field is required."],
    "Age": ["Age must be between 0 and 150."]
  }
}

Automatic Validation in Controllers

Controllers with [ApiController] automatically return ValidationProblemDetails for invalid model state:

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    [HttpPost]
    public IActionResult CreateUser([FromBody] CreateUserRequest request)
    {
        // If ModelState is invalid, ASP.NET Core automatically returns
        // ValidationProblemDetails with 400 status
        return Ok(CreateUser(request));
    }
}

public class CreateUserRequest
{
    [Required]
    [EmailAddress]
    public string Email { get; set; } = "";

    [Range(0, 150)]
    public int Age { get; set; }
}

Custom Validation Response Factory

Customize the validation response format:

builder.Services.AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.InvalidModelStateResponseFactory = context =>
        {
            var problemDetails = new ValidationProblemDetails(context.ModelState)
            {
                Type = "https://example.com/problems/validation-error",
                Title = "Validation failed",
                Status = StatusCodes.Status400BadRequest,
                Instance = context.HttpContext.Request.Path
            };

            return new BadRequestObjectResult(problemDetails)
            {
                ContentTypes = { "application/problem+json" }
            };
        };
    });

Handling FluentValidation Errors

If using FluentValidation, convert validation failures to ValidationProblemDetails:

public class ValidationExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is not ValidationException validationException)
        {
            return false;
        }

        var errors = validationException.Errors
            .GroupBy(e => e.PropertyName)
            .ToDictionary(
                g => g.Key,
                g => g.Select(e => e.ErrorMessage).ToArray());

        var problemDetails = new ValidationProblemDetails(errors)
        {
            Status = StatusCodes.Status400BadRequest,
            Title = "Validation failed",
            Instance = httpContext.Request.Path
        };

        httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);

        return true;
    }
}

Sensitive Data in Errors

The Problem

Exposing internal details creates security vulnerabilities:

{
  "type": "https://example.com/errors/database",
  "title": "Database Error",
  "status": 500,
  "detail": "SqlException: Login failed for user 'sa'. Server: prod-db-01.internal"
}

This exposes server names, usernames, and infrastructure details.

Environment-Aware Responses

Include details only in development:

public class GlobalExceptionHandler(
    ILogger<GlobalExceptionHandler> logger,
    IHostEnvironment environment) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier;

        logger.LogError(
            exception,
            "Unhandled exception. TraceId: {TraceId}",
            traceId);

        var problemDetails = new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title = "An error occurred while processing your request.",
            Instance = httpContext.Request.Path
        };

        // Always include trace ID for correlation
        problemDetails.Extensions["traceId"] = traceId;

        // Only include sensitive details in development
        if (environment.IsDevelopment())
        {
            problemDetails.Detail = exception.ToString();
        }

        httpContext.Response.StatusCode = problemDetails.Status.Value;
        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);

        return true;
    }
}

Safe Error Messages

Map internal exceptions to user-safe messages:

private static string GetSafeErrorMessage(Exception exception) => exception switch
{
    DbUpdateException => "A database error occurred. Please try again.",
    TimeoutException => "The operation timed out. Please try again.",
    HttpRequestException => "An external service is unavailable.",
    _ => "An unexpected error occurred."
};

Correlation IDs

Always include a trace ID so users can reference specific errors in support requests:

problemDetails.Extensions["traceId"] = Activity.Current?.Id ?? httpContext.TraceIdentifier;

Response:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "traceId": "00-abc123-def456-00"
}

Customizing ProblemDetails

Using CustomizeProblemDetails

Modify all ProblemDetails responses in one place:

builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = context =>
    {
        // Always include trace ID
        context.ProblemDetails.Extensions["traceId"] =
            Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;

        // Include timestamp
        context.ProblemDetails.Extensions["timestamp"] = DateTime.UtcNow;

        // Set type URIs based on status code
        context.ProblemDetails.Type ??= context.ProblemDetails.Status switch
        {
            400 => "https://example.com/problems/bad-request",
            404 => "https://example.com/problems/not-found",
            500 => "https://example.com/problems/internal-error",
            _ => "https://example.com/problems/unknown"
        };
    };
});

Custom ProblemDetailsFactory

For complete control, implement a custom factory:

public class CustomProblemDetailsFactory : ProblemDetailsFactory
{
    public override ProblemDetails CreateProblemDetails(
        HttpContext httpContext,
        int? statusCode = null,
        string? title = null,
        string? type = null,
        string? detail = null,
        string? instance = null)
    {
        var problemDetails = new ProblemDetails
        {
            Status = statusCode ?? StatusCodes.Status500InternalServerError,
            Title = title ?? GetDefaultTitle(statusCode),
            Type = type ?? GetDefaultType(statusCode),
            Detail = detail,
            Instance = instance ?? httpContext.Request.Path
        };

        problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier;

        return problemDetails;
    }

    public override ValidationProblemDetails CreateValidationProblemDetails(
        HttpContext httpContext,
        ModelStateDictionary modelStateDictionary,
        int? statusCode = null,
        string? title = null,
        string? type = null,
        string? detail = null,
        string? instance = null)
    {
        var problemDetails = new ValidationProblemDetails(modelStateDictionary)
        {
            Status = statusCode ?? StatusCodes.Status400BadRequest,
            Title = title ?? "Validation failed",
            Type = type ?? "https://example.com/problems/validation-error",
            Instance = instance ?? httpContext.Request.Path
        };

        problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier;

        return problemDetails;
    }

    private static string GetDefaultTitle(int? statusCode) => statusCode switch
    {
        400 => "Bad Request",
        404 => "Not Found",
        500 => "Internal Server Error",
        _ => "Error"
    };

    private static string GetDefaultType(int? statusCode) =>
        $"https://httpstatuses.io/{statusCode ?? 500}";
}

Register the custom factory:

builder.Services.AddTransient<ProblemDetailsFactory, CustomProblemDetailsFactory>();

Copy/Paste Artifact: Production Error Handling

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = context =>
    {
        context.ProblemDetails.Extensions["traceId"] =
            Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
    };
});

builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

var app = builder.Build();

app.UseExceptionHandler();
app.UseStatusCodePages();

app.MapControllers();
app.Run();

// GlobalExceptionHandler.cs
public class GlobalExceptionHandler(
    ILogger<GlobalExceptionHandler> logger,
    IHostEnvironment environment) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier;

        logger.LogError(exception, "Unhandled exception. TraceId: {TraceId}", traceId);

        var problemDetails = new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title = "An error occurred while processing your request.",
            Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
            Instance = httpContext.Request.Path
        };

        problemDetails.Extensions["traceId"] = traceId;

        if (environment.IsDevelopment())
        {
            problemDetails.Detail = exception.ToString();
        }

        httpContext.Response.StatusCode = problemDetails.Status.Value;
        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);

        return true;
    }
}

Common Failure Modes

Inconsistent Error Formats

Some endpoints return ProblemDetails, others return custom formats:

// Bad: Mixed response types
return new { error = "Not found" };        // Custom format
return NotFound();                          // Empty response
return Problem("Not found");               // ProblemDetails

Solution: Use ProblemDetails consistently across all endpoints. Use Results.Problem() in Minimal APIs and Problem() in controllers.

Leaking Stack Traces

Production responses include exception details:

{
  "detail": "System.NullReferenceException: Object reference not set...\n   at OrderService.GetOrder()"
}

Solution: Only include details in development environments. Log the full exception server-side.

Missing Correlation

Errors don't include trace IDs, making debugging impossible:

Solution: Always include traceId in the extensions. Log with the same trace ID server-side.

Validation Errors as 500

Validation exceptions thrown from services return 500 instead of 400:

// Bad: Validation thrown as exception returns 500
throw new Exception("Email is invalid");

Solution: Use ValidationException with a dedicated handler that returns 400, or validate in controllers before calling services.

Checklist

Before deploying error handling:

  • AddProblemDetails() registered
  • UseExceptionHandler() in middleware pipeline
  • Global exception handler logs all exceptions
  • Trace ID included in all error responses
  • Sensitive details excluded in production
  • Domain exceptions mapped to appropriate status codes
  • Validation errors return 400 with ValidationProblemDetails
  • Consistent ProblemDetails format across all endpoints
  • Error type URIs documented for clients

FAQ

Should I use IExceptionHandler or middleware?

Use IExceptionHandler for .NET 8+. It's cleaner, supports DI, and allows multiple handlers. Use middleware only for older versions or special cases.

What status code for business rule violations?

Use 422 Unprocessable Entity for validation that passes format checks but fails business rules. Use 400 Bad Request for format/syntax errors.

How do I include field-level errors?

Use ValidationProblemDetails with its Errors dictionary, or add an errors extension to regular ProblemDetails.

Should I use custom type URIs?

For simple APIs, the default RFC links are fine. For larger APIs, define custom type URIs that link to your documentation.

How do I handle 404 for missing routes vs missing resources?

Configure UseStatusCodePages() for route 404s. Return Problem() with 404 from controllers for missing resources. Both produce ProblemDetails.

What to do next

Enable AddProblemDetails() in your application today. Then implement IExceptionHandler to customize error responses and add correlation ID logging.

For more on building robust ASP.NET Core APIs, read Minimal API Validation in .NET 10.

If you want help implementing consistent error handling in your API, reach out via Contact.

References

Author notes

Decisions:

  • Use IExceptionHandler for global exception handling. Rationale: cleaner than middleware-based exception filters and integrates with ASP.NET Core's error handling pipeline.
  • Include correlation IDs in all error responses. Rationale: enables matching client-reported errors with server logs.
  • Never include stack traces or internal details in production error responses. Rationale: security risk and not useful to API consumers.

Observations:

  • Inconsistent error formats across endpoints confuse API consumers and complicate client error handling.
  • Missing correlation IDs make production debugging significantly harder.
  • Overly detailed error messages in production have led to information disclosure vulnerabilities.