Your [Authorize] Attribute Is Compliance Theater

Your [Authorize] Attribute Is Compliance Theater

Authentication proves identity. Authorization enforces access rights. ISO/IEC 27001 Control A.9 requires both — and teams consistently confuse them.

I’ve reviewed too many ASP.NET Core applications where authentication exists but authorization is theater. Teams feel secure because they see [Authorize] attributes on controllers. Then you find [AllowAnonymous] scattered across action methods, hardcoded role strings in business logic, and zero audit trail when authorization fails.

This isn’t just bad architecture. It’s a violation of ISO/IEC 27001 Control A.9 (Access Control), which explicitly requires:

  • A.9.1: Business requirements of access control
  • A.9.2: User access management
  • A.9.4: System and application access control

The standard doesn’t care that your authentication works. It demands that access rights are enforced based on business rules, managed centrally, and audited comprehensively. String-based role checks scattered across your codebase don’t satisfy any of those requirements.

Here’s what inadequate access control looks like in the codebases I’ve reviewed, why auditors flag it immediately, and what actually works.

The Fatal Pattern: Authentication Without Authorization

Here’s what I encounter repeatedly in ASP.NET Core applications that claim to be “secure”:

[Authorize] // Authentication required...
public class DocumentsController : ControllerBase
{
    // ...but anyone authenticated can access everything
    [HttpGet("{id}")]
    [AllowAnonymous] // Wait, why is this here?
    public async Task<IActionResult> GetDocument(int id) { /* ... */ }
    
    [HttpPost]
    public async Task<IActionResult> CreateDocument(CreateDocumentRequest request)
    {
        // Authorization check buried in business logic
        if (!User.IsInRole("DocumentCreator"))
            return Forbid(); // No audit trail
        /* ... */
    }
    
    [HttpDelete("{id}")]
    public async Task<IActionResult> DeleteDocument(int id)
    {
        // Different pattern, same problem
        var userRoles = User.Claims
            .Where(c => c.Type == ClaimTypes.Role)
            .Select(c => c.Value);
            
        if (!userRoles.Contains("Admin") && !userRoles.Contains("DocumentManager"))
            return Forbid(); // Still no audit trail
        /* ... */
    }
}

This code violates multiple ISO 27001 controls simultaneously:

Violation 1: Inconsistent Access Control (A.9.4.1)

The [AllowAnonymous] attribute on GetDocument contradicts the controller-level [Authorize] attribute. Why does document retrieval bypass authentication entirely? This creates an access control gap that auditors will flag immediately.

ISO requirement: “Access to systems and applications shall be controlled in accordance with access control policy.”

There is no coherent access control policy here. Anonymous access for reads, role checks for writes, and no documented business justification.

Violation 2: Decentralized Authorization Logic (A.9.2.1)

Authorization decisions are scattered across controller actions using different patterns:

  • String-based role checks: User.IsInRole("DocumentCreator")
  • Manual claim enumeration: User.Claims.Where(...)
  • Inconsistent role requirements: "Admin" vs "DocumentManager" with no explanation

ISO requirement: “A formal user access provisioning process shall be implemented to assign or revoke access rights.”

How do you audit these access rights? Where is the central policy that defines who can create, read, update, or delete documents? The authorization rules are embedded in controller code. Every change requires a deployment. Every audit requires reading through controller implementations.

Violation 3: No Audit Trail (A.9.4.5)

When authorization fails, the application returns 403 Forbidden. But where’s the audit log?

ISO requirement: “Access to information and application system functions shall be restricted in accordance with the access control policy.”

The standard requires you to demonstrate that access controls are enforced. Without audit logs of authorization failures, you cannot prove compliance. Returning Forbid() silently discards evidence that the control was tested.

Violation 4: Brittle Role Management (A.9.2.2)

Hardcoded role strings like "DocumentCreator" and "Admin" create maintenance nightmares. When business requirements change:

  • Which role takes precedence?
  • Who decides when roles should be renamed?
  • How do you handle role deprecation without breaking production?

ISO requirement: “User access rights shall be reviewed at regular intervals.”

You can’t review access rights that are hardcoded strings scattered across dozens of controllers. Every review requires grepping the codebase and hoping developers remembered to update every location.

What Actually Works: Policy-Based Authorization

ISO 27001 Control A.9 is satisfied when authorization is:

  1. Centralized — defined in one location, not scattered across controllers
  2. Policy-driven — based on business requirements, not implementation details
  3. Auditable — authorization decisions are logged comprehensively
  4. Testable — policies can be verified independently of controllers

Here’s how ASP.NET Core implements this correctly:

Step 1: Define Authorization Policies

// Program.cs
builder.Services.AddAuthorization(options =>
{
    // Business requirements become named policies
    options.AddPolicy("ViewDocuments", policy =>
        policy.RequireClaim("permission", "documents.read"));
    
    options.AddPolicy("CreateDocuments", policy =>
        policy.RequireClaim("permission", "documents.write"));
    
    // Resource-based authorization for ownership
    options.AddPolicy("EditOwnDocuments", policy =>
        policy.Requirements.Add(new DocumentOwnershipRequirement()));
});

This satisfies A.9.1 (Business requirements of access control) by codifying access requirements as named policies that reflect business operations, not technical role names.

Step 2: Implement Custom Authorization Handlers

For complex business rules, use authorization handlers:

public class DocumentOwnershipRequirement : IAuthorizationRequirement { }

public class DocumentOwnershipHandler : AuthorizationHandler<DocumentOwnershipRequirement, Document>
{
    private readonly ILogger<DocumentOwnershipHandler> _logger;
    
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        DocumentOwnershipRequirement requirement,
        Document resource)
    {
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        
        if (userId == resource.OwnerId)
        {
            context.Succeed(requirement);
        }
        else
        {
            // A.9.4.5: Audit authorization failure
            _logger.LogWarning("User {UserId} denied access to document {DocumentId}",
                userId, resource.Id);
        }
        
        return Task.CompletedTask;
    }
}

This satisfies A.9.4.1 (Restriction of access to information) by centralizing business logic that determines access rights. Authorization decisions are made in one location, not repeated across controllers.

Step 3: Apply Policies Declaratively

[Authorize]
public class DocumentsController : ControllerBase
{
    private readonly IAuthorizationService _authorizationService;
    private readonly ILogger<DocumentsController> _logger;
    
    [HttpGet("{id}")]
    [Authorize(Policy = "ViewDocuments")]  // Declarative policy
    public async Task<IActionResult> GetDocument(int id)
    {
        var document = await _documentService.GetDocumentAsync(id);
        if (document == null) return NotFound();
        
        // Resource-based authorization for ownership
        var authResult = await _authorizationService
            .AuthorizeAsync(User, document, "EditOwnDocuments");
        
        if (!authResult.Succeeded)
        {
            // A.9.4.5: Audit authorization failure
            _logger.LogWarning(
                "User {UserId} denied access to document {DocumentId}",
                User.FindFirst(ClaimTypes.NameIdentifier)?.Value, id);
            return Forbid();
        }
        
        return Ok(document);
    }
    
    [HttpPost]
    [Authorize(Policy = "CreateDocuments")]
    public async Task<IActionResult> CreateDocument(CreateDocumentRequest request)
    {
        _logger.LogInformation("User {UserId} creating document",
            User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
        /* ... */
    }
}

Key Improvements

  1. Centralized Policy Definition: All authorization logic lives in Program.cs, not scattered across controllers. When business requirements change, you update policies in one location.

  2. Claims-Based Permissions: Instead of role strings, use permission claims (documents.read, documents.write, documents.manage). This separates identity (who you are) from authorization (what you can do).

  3. Resource-Based Authorization: Use IAuthorizationService for dynamic checks that depend on resource properties (e.g., document ownership). This handles scenarios where static policies aren’t sufficient.

  4. Comprehensive Audit Logging: Every authorization decision is logged with context (user ID, resource ID, action attempted). This satisfies A.9.4.5 audit requirements.

Testing Authorization Policies

ISO 27001 Control A.12.1.2 requires testing of security controls. Here’s how to verify authorization policies through automated integration tests:

public class DocumentAuthorizationTests : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task GetDocument_WithoutAuthentication_Returns401()
    {
        var client = _factory.CreateClient();
        var response = await client.GetAsync("/api/documents/1");
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }
    
    [Fact]
    public async Task GetDocument_WithoutViewPermission_Returns403()
    {
        var client = _factory.CreateClient();
        client.DefaultRequestHeaders.Authorization = 
            new AuthenticationHeaderValue("Bearer", TokenWithoutReadPermission());
        
        var response = await client.GetAsync("/api/documents/1");
        Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
    }
}

GitHub Actions Integration

Run these tests in CI/CD to verify authorization policies before deployment:

name: Security Compliance

on:
  pull_request:
    paths:
      - 'src/**/*.cs'
      - 'tests/**/*.cs'

jobs:
  authorization-tests:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'
      
      - name: Run Authorization Tests
        run: dotnet test --filter "Category=Authorization" --logger "trx;LogFileName=authorization-results.trx"
      
      - name: Publish Test Results
        if: always()
        uses: EnricoMi/publish-unit-test-result-action@v2
        with:
          files: '**/authorization-results.trx'

This satisfies A.12.1.2 (Testing of security controls) by automating verification that access controls function as intended.

Health Checks for Authentication Services

ISO 27001 Control A.17.1.1 requires monitoring availability of critical services. Authentication is a critical service. Here’s how to monitor it:

// Program.cs
builder.Services.AddHealthChecks()
    .AddCheck<AuthenticationServiceHealthCheck>("authentication");

public class AuthenticationServiceHealthCheck : IHealthCheck
{
    private readonly IConfiguration _configuration;
    private readonly HttpClient _httpClient;
    
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        var authEndpoint = _configuration["Authentication:Authority"];
        
        try
        {
            var response = await _httpClient.GetAsync(
                $"{authEndpoint}/.well-known/openid-configuration",
                cancellationToken);
            
            return response.IsSuccessStatusCode
                ? HealthCheckResult.Healthy()
                : HealthCheckResult.Degraded($"Status: {response.StatusCode}");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("Authentication unavailable", ex);
        }
    }
}

// Expose endpoint
app.MapHealthChecks("/health");

This enables external monitoring systems to verify authentication service availability, satisfying operational monitoring requirements.

Mapping ISO Controls to Implementation

Here’s how policy-based authorization satisfies specific ISO 27001 requirements:

ISO ControlRequirementImplementation
A.9.1.1Access control policyCentralized policy definitions in Program.cs
A.9.2.1User access provisioningClaims-based permissions managed via identity provider
A.9.2.2User access rights managementPermission claims reviewed and updated independently of code
A.9.4.1Information access restriction[Authorize(Policy = "...")] attributes enforce policy
A.9.4.5Access control to program source codeResource-based authorization with IAuthorizationService
A.12.1.2Testing of security controlsAutomated integration tests verify authorization logic
A.12.4.1Event loggingComprehensive audit logs of authorization decisions
A.17.1.1Continuity capabilitiesHealth checks monitor authentication service availability

What This Means for Your Team

If you’re building ASP.NET Core applications that need to satisfy ISO 27001 compliance:

  1. Stop using role strings in business logic. They scatter authorization decisions across your codebase, making audits painful and policy changes risky.

  2. Define authorization policies that map to business operations. "ViewDocuments" is clearer than checking whether User.IsInRole("DocumentReader") — though you’ll need both policies and resource-based checks for complex scenarios.

  3. Use claims for permissions, not roles. Roles represent groups. Permissions represent capabilities. ISO 27001 cares about capabilities.

  4. Implement comprehensive audit logging. Every authorization failure must be logged with sufficient context to reconstruct what happened and why.

  5. Test your authorization policies. Write integration tests that verify policies work correctly. Run them in CI/CD before deployment.

  6. Monitor authentication service availability. Health checks aren’t optional. They’re how you demonstrate operational monitoring.

Authentication proves identity. Authorization enforces access rights. ISO/IEC 27001 Control A.9 requires both.

The gap between [Authorize] and actual access control is where I find compliance violations in every audit. Policy-based authorization closes that gap — when you map policies to business operations, not developer convenience.

Comments

VG Wort