# Authentication and Authorization in Clean Architecture

> Authentication is infrastructure, authorization is a business rule, and confusing the two is how ASP.NET Core leaks into your domain. Here is where JWT validation, the ICurrentUserService abstraction, permission checks, and ownership rules each live, and how to keep them enforced beyond HTTP endpoints.

Published: 2026-08-11. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/authentication-authorization-clean-architecture

In Clean Architecture, authentication is plumbing that belongs at the edge, while authorization is business logic that belongs in the core.
Every application needs to answer two questions (who is calling, and are they allowed to do this?), and the answers live in different layers.
Get the split wrong and you either couple your domain to ASP.NET Core or scatter permission checks across controllers where half your entry points never see them.

## The Question

[**Clean Architecture**](https://milanjovanovic.tech/blog/clean-architecture-and-the-benefits-of-structured-software-design) has strict rules about dependencies.
So where do authentication and authorization fit?

- **Authentication** (who are you?) is an infrastructure concern
- **Authorization** (can you do this?) spans multiple layers

Let me show you how to implement both without violating layer boundaries.

## Authentication: Infrastructure Layer

Authentication is how users prove their identity - JWT tokens, cookies, OAuth flows.
This is entirely an [**Infrastructure layer**](https://milanjovanovic.tech/blog/infrastructure-layer-clean-architecture) concern.

Configure [**JWT authentication**](https://milanjovanovic.tech/blog/jwt-authentication-aspnetcore) in the Presentation or Infrastructure layer:

```csharp
// Infrastructure/Authentication/JwtConfiguration.cs
public static class JwtConfiguration
{
    public static IServiceCollection AddJwtAuthentication(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidateAudience = true,
                    ValidateLifetime = true,
                    ValidateIssuerSigningKey = true,
                    ValidIssuer = configuration["Jwt:Issuer"],
                    ValidAudience = configuration["Jwt:Audience"],
                    IssuerSigningKey = new SymmetricSecurityKey(
                        Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"]!))
                };
            });

        return services;
    }
}
```

The Application layer doesn't know _how_ users are authenticated.
It only knows _who_ the current user is.

## The Current User Abstraction

Define an interface in the Application layer:

```csharp
// Application/Abstractions/ICurrentUserService.cs
public interface ICurrentUserService
{
    Guid UserId { get; }
    string Email { get; }
    IReadOnlyCollection<string> Roles { get; }
    IReadOnlyCollection<string> Permissions { get; }
    bool IsAuthenticated { get; }
}
```

Implement it in Infrastructure:

```csharp
// Infrastructure/Authentication/CurrentUserService.cs
public class CurrentUserService : ICurrentUserService
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public CurrentUserService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public Guid UserId => Guid.Parse(
        _httpContextAccessor.HttpContext?.User
            .FindFirstValue(ClaimTypes.NameIdentifier) ?? Guid.Empty.ToString());

    public string Email =>
        _httpContextAccessor.HttpContext?.User
            .FindFirstValue(ClaimTypes.Email) ?? string.Empty;

    public IReadOnlyCollection<string> Roles =>
        _httpContextAccessor.HttpContext?.User
            .FindAll(ClaimTypes.Role)
            .Select(c => c.Value)
            .ToList() ?? [];

    public IReadOnlyCollection<string> Permissions =>
        _httpContextAccessor.HttpContext?.User
            .FindAll("permission")
            .Select(c => c.Value)
            .ToList() ?? [];

    public bool IsAuthenticated =>
        _httpContextAccessor.HttpContext?.User.Identity?.IsAuthenticated ?? false;
}
```

Register it:

```csharp
services.AddHttpContextAccessor();
services.AddScoped<ICurrentUserService, CurrentUserService>();
```

Now your handlers can access the current user without depending on ASP.NET Core:

```csharp
public sealed class GetMyOrdersQueryHandler
    : IQueryHandler<GetMyOrdersQuery, List<OrderResponse>>
{
    private readonly ICurrentUserService _currentUser;
    private readonly IOrderRepository _orderRepository;

    public GetMyOrdersQueryHandler(
        ICurrentUserService currentUser,
        IOrderRepository orderRepository)
    {
        _currentUser = currentUser;
        _orderRepository = orderRepository;
    }

    public async Task<Result<List<OrderResponse>>> Handle(
        GetMyOrdersQuery query, CancellationToken ct)
    {
        var orders = await _orderRepository.GetByCustomerIdAsync(
            _currentUser.UserId, ct);

        return orders.Select(o => o.ToResponse()).ToList();
    }
}
```

I go deeper on this abstraction (and a few variations of it) in [**getting the current user in Clean Architecture**](https://milanjovanovic.tech/blog/getting-the-current-user-in-clean-architecture).

One gotcha to watch for: if you populate roles or permissions from an external identity provider, the claims may not arrive under the claim types you expect.
[**Claims transformation**](https://milanjovanovic.tech/blog/master-claims-transformation-for-flexible-aspnetcore-authorization) is the right place to normalize them before your `CurrentUserService` reads them.

Another JWT-specific gotcha: `CurrentUserService` reads `ClaimTypes.NameIdentifier`, which only exists because ASP.NET Core remaps the token's `sub` claim by default.
If you disable that mapping with `options.MapInboundClaims = false`, read the `sub` claim directly instead.
Otherwise `UserId` silently falls back to `Guid.Empty`.

## Authorization: Application Layer

Authorization logic lives in the Application layer because it's a business rule: "Only managers can approve orders over $10,000."

### Option 1: In the Handler

For simple authorization checks:

```csharp
public sealed class ApproveOrderCommandHandler
    : ICommandHandler<ApproveOrderCommand>
{
    private readonly ICurrentUserService _currentUser;
    private readonly IOrderRepository _orderRepository;
    private readonly IUnitOfWork _unitOfWork;

    public ApproveOrderCommandHandler(
        ICurrentUserService currentUser,
        IOrderRepository orderRepository,
        IUnitOfWork unitOfWork)
    {
        _currentUser = currentUser;
        _orderRepository = orderRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result> Handle(
        ApproveOrderCommand command, CancellationToken ct)
    {
        if (!_currentUser.Roles.Contains("Manager"))
        {
            return Result.Failure(AuthorizationErrors.InsufficientRole("Manager"));
        }

        var order = await _orderRepository.GetByIdAsync(command.OrderId, ct);

        if (order is null)
        {
            return Result.Failure(OrderErrors.NotFound(command.OrderId));
        }

        order.Approve(_currentUser.UserId);
        await _unitOfWork.SaveChangesAsync(ct);

        return Result.Success();
    }
}
```

### Option 2: Authorization Pipeline Behavior

For declarative, reusable **permission-based authorization**, decorate the command with the required permission:

```csharp
// Application/Authorization/AuthorizeAttribute.cs
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class AuthorizeAttribute : Attribute
{
    public string? Permission { get; set; }
    public string? Role { get; set; }
}

// Decorate the command
[Authorize(Permission = "orders:approve")]
public sealed record ApproveOrderCommand(Guid OrderId) : ICommand;
```

Then create a pipeline behavior that enforces it:

```csharp
public class AuthorizationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ICurrentUserService _currentUser;

    public AuthorizationBehavior(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var authorizeAttributes = request
            .GetType()
            .GetCustomAttributes<AuthorizeAttribute>()
            .ToList();

        if (authorizeAttributes.Count == 0)
        {
            return await next();
        }

        foreach (var attribute in authorizeAttributes)
        {
            if (attribute.Permission is not null &&
                !_currentUser.Permissions.Contains(attribute.Permission))
            {
                throw new ForbiddenAccessException(attribute.Permission);
            }

            if (attribute.Role is not null &&
                !_currentUser.Roles.Contains(attribute.Role))
            {
                throw new ForbiddenAccessException(attribute.Role);
            }
        }

        return await next();
    }
}
```

The `ForbiddenAccessException` is a simple custom exception defined in the Application layer:

```csharp
public sealed class ForbiddenAccessException : Exception
{
    public ForbiddenAccessException(string requirement)
        : base($"Access denied. Missing requirement: {requirement}")
    {
    }
}
```

Your global exception handler in the Presentation layer translates it to a `403 Forbidden` response.
If you prefer to avoid exceptions for flow control, you can constrain `TResponse` to your `Result` type and return a failure instead; the tradeoff is some reflection to construct `Result<T>` failures generically.

This approach separates _what_ permissions are needed (attribute on the command) from _how_ they're enforced (pipeline behavior).

### Option 3: Resource-Based Authorization

When authorization depends on the resource itself:

```csharp
public sealed class UpdateProjectCommandHandler
    : ICommandHandler<UpdateProjectCommand>
{
    private readonly ICurrentUserService _currentUser;
    private readonly IProjectRepository _projectRepository;
    private readonly IUnitOfWork _unitOfWork;

    public UpdateProjectCommandHandler(
        ICurrentUserService currentUser,
        IProjectRepository projectRepository,
        IUnitOfWork unitOfWork)
    {
        _currentUser = currentUser;
        _projectRepository = projectRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result> Handle(
        UpdateProjectCommand command, CancellationToken ct)
    {
        var project = await _projectRepository.GetByIdAsync(
            command.ProjectId, ct);

        if (project is null)
        {
            return Result.Failure(ProjectErrors.NotFound(command.ProjectId));
        }

        if (!project.IsOwner(_currentUser.UserId) &&
            !project.IsMember(_currentUser.UserId))
        {
            return Result.Failure(AuthorizationErrors.Forbidden());
        }

        project.Update(command.Name, command.Description);
        await _unitOfWork.SaveChangesAsync(ct);

        return Result.Success();
    }
}
```

The authorization rule (`IsOwner` or `IsMember`) is part of the domain model.

## What About the [Authorize] Attribute?

You should still use ASP.NET Core's `[Authorize]` attribute (or `RequireAuthorization()` on Minimal API endpoints).
Just be clear about its job.

Endpoint-level authorization is a **coarse gate**: is the caller authenticated, does the token carry the right scope?
It protects the HTTP entry point, and only the HTTP entry point.

The permission checks in your Application layer are the real enforcement.
They run no matter how the use case is invoked: HTTP endpoint, background job, message consumer, or a test.
If your only authorization lives in controller attributes, every non-HTTP entry point bypasses it.

Use both, at different granularities:

- **Presentation**: `[Authorize]` for "must be authenticated" and scope checks
- **Application**: permission and role checks per use case
- **Domain**: ownership and membership rules on the aggregate

## The Background Job Gotcha

Here's the failure mode that bites almost everyone eventually.

`CurrentUserService` reads from `IHttpContextAccessor`.
In a background job or a message consumer, there is no HTTP context, so `HttpContext` is `null` and `UserId` silently becomes `Guid.Empty`.
Your audit trail now says "nobody" approved the order.

Two ways to handle it:

1. **Pass the user explicitly.** When a use case is triggered from a message, include the acting user's ID in the message payload and flow it into the command. This is my default: it's explicit and survives serialization boundaries.
2. **Swap the implementation.** Register a different `ICurrentUserService` for worker processes (for example, one representing a system account). This works well for genuinely system-initiated operations like scheduled cleanups.

Whichever you pick, make `IsAuthenticated` meaningful in both contexts and fail loudly (not with `Guid.Empty`) when a use case requires a real user.

## The Layer Boundaries

Here's the summary of where each concern lives:

![Authentication and authorization concerns split across layers: Presentation holds the Authorize attribute and 401/403 responses, Application holds permission checks and the ICurrentUserService interface, Infrastructure implements JWT validation and CurrentUserService, and Domain holds ownership rules](https://milanjovanovic.tech/blogs/articles/authentication-authorization-clean-architecture/auth-concerns-by-layer.png)

- **JWT validation, cookie handling**: Infrastructure
- **`ICurrentUserService` interface**: Application
- **`CurrentUserService` implementation**: Infrastructure
- **Permission checks**: Application (pipeline behavior or handler)
- **Resource-based ownership rules**: Domain
- **`[Authorize]` attribute and 401/403 responses**: Presentation

## Key Takeaways

Authentication is infrastructure.
Authorization is a business rule.

1. Define `ICurrentUserService` in the Application layer
2. Implement it in Infrastructure using `IHttpContextAccessor`
3. Use pipeline behaviors for permission-based authorization
4. Put resource-based authorization in handlers or domain entities
5. Keep `[Authorize]` for coarse endpoint-level checks only
6. Never let your Application layer depend on ASP.NET Core directly

This keeps your business logic testable - mock `ICurrentUserService` in tests instead of wrestling with HTTP contexts.

Thanks for reading, and stay awesome!

---

## Frequently asked questions

### Where does authentication belong in Clean Architecture?

Authentication is an infrastructure concern. JWT validation, cookies, and OAuth flows live in the Infrastructure or Presentation layer. The Application layer only sees an abstraction like ICurrentUserService that tells it who the current user is.

### Is authorization a business rule or an infrastructure concern?

Authorization is mostly a business rule. Rules like "only managers can approve orders" belong in the Application layer, and ownership rules like "only the project owner can edit it" belong in the domain model. Only the transport-level enforcement, like returning a 403, is a presentation concern.

### How do handlers access the current user without depending on ASP.NET Core?

Define an ICurrentUserService interface in the Application layer and implement it in Infrastructure using IHttpContextAccessor. Handlers depend on the interface, so they stay testable and free of HTTP concerns.

### Should I use the Authorize attribute in Clean Architecture?

Yes, but only for coarse checks like requiring an authenticated user or a valid scope. Fine-grained permission checks belong in the Application layer so they are enforced for every entry point, not just HTTP endpoints.

### What happens to ICurrentUserService in background jobs where there is no HTTP context?

IHttpContextAccessor returns null outside a request, so an HttpContext-based implementation fails. Pass the acting user ID explicitly in the message or job payload, or register a different ICurrentUserService implementation for non-HTTP entry points.
