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 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 concern.
Configure JWT authentication in the Presentation or Infrastructure layer:
// 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:
// 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:
// 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:
services.AddHttpContextAccessor();
services.AddScoped<ICurrentUserService, CurrentUserService>();
Now your handlers can access the current user without depending on ASP.NET Core:
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.
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 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:
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:
// 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:
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:
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:
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:
- 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.
- Swap the implementation. Register a different
ICurrentUserServicefor 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:
- JWT validation, cookie handling: Infrastructure
ICurrentUserServiceinterface: ApplicationCurrentUserServiceimplementation: 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.
- Define
ICurrentUserServicein the Application layer - Implement it in Infrastructure using
IHttpContextAccessor - Use pipeline behaviors for permission-based authorization
- Put resource-based authorization in handlers or domain entities
- Keep
[Authorize]for coarse endpoint-level checks only - 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.



