Cookie Banners Won’t Save You From ISO 27701
That boolean column you call “consent”? It’s a liability bomb with a ticking clock.
I’ve lost count of how many production codebases I’ve audited where “consent management” meant a single AcceptedTerms property. Cookie banners everywhere. “Accept All” buttons doing the heavy lifting. Privacy policies buried in legal prose with one acknowledgment click covering everything from marketing emails to behavioral profiling.
Then the auditor asks: “Show me the consent record for user X, including when they agreed, to what version of your policy, and how they can withdraw.” And suddenly that boolean column looks very, very thin.
That’s not consent management. That’s compliance theater.
ISO/IEC 27701 Control 7.3.2 (“Providing data subjects with choices”) and GDPR (General Data Protection Regulation) Article 7 establish clear requirements: consent must be specific, informed, freely given, and provable. Withdrawal must be as easy as granting. And the audit trail must survive regulatory scrutiny.
Most implementations fail all of these. Here’s how to build consent management in .NET that actually works.
The Fatal Pattern: Consent as an Afterthought
This is the implementation I’ve seen deployed at companies processing millions of user records:
public class ApplicationUser : IdentityUser
{
public bool AcceptedTerms { get; set; } // The entire "consent system"
public DateTime? AcceptedTermsDate { get; set; }
}
// Registration: one checkbox, forever consent
var user = new ApplicationUser
{
AcceptedTerms = true,
AcceptedTermsDate = DateTime.UtcNow
};
Five critical failures in six lines:
No granularity. One checkbox covers terms, privacy policy, marketing emails, analytics, and third-party data sharing. ISO 27701 7.3.2 explicitly requires separate consent for each distinct processing purpose.
No audit trail. When
AcceptedTermsflips fromtruetofalse, you’ve lost the original consent record. No timestamp of the change. No reason captured. No policy version recorded.No expiration. Consent granted under your 2020 privacy policy remains “valid” under your 2026 policy, even though the processing activities may be completely different. GDPR Article 7(3) requires consent to remain valid only for the specific purpose and context in which it was given.
No withdrawal mechanism. Users can’t selectively opt out of marketing while staying opted in to analytics. It’s nuclear: all or nothing. This violates ISO 27701 7.3.4’s requirement for granular control.
No enforcement. Nothing stops your email service from blasting promotional content to users who never consented to marketing. The consent record is decorative, not functional.
This pattern satisfies one requirement: showing auditors that a checkbox exists. It satisfies nothing else.
The Correct Pattern: Purpose-Specific Consent with Audit
Real consent management requires four components working together: granular consent records, immutable audit logs, validation middleware, and expiration workflows. Each piece addresses a specific regulatory requirement. Let’s build them.
Step 1: Model Consent Granularly
Start by defining consent purposes as distinct entities. Each represents a specific data processing activity requiring explicit user approval:
public enum ConsentPurpose
{
TermsOfService, // Required: platform access
PrivacyPolicy, // Required: data processing basis
MarketingEmails, // Optional: promotional content
AnalyticsTracking, // Optional: usage telemetry
ThirdPartyDataSharing, // Optional: partner integrations
ProfilingAndPersonalization // Optional: behavioral targeting
}
public class UserConsent
{
public int Id { get; set; }
public string UserId { get; set; }
public ConsentPurpose Purpose { get; set; }
public bool IsGranted { get; set; }
public DateTime GrantedAt { get; set; }
public DateTime? ExpiresAt { get; set; } // Consent can expire
public string PolicyVersion { get; set; } // Which policy version was accepted
public string? IpAddress { get; set; } // Audit context
public string? UserAgent { get; set; }
public ApplicationUser User { get; set; }
}
This separates each consent purpose into its own record. Marketing consent lives independently from analytics consent. Withdrawing one leaves others intact. Exactly what ISO 27701 7.3.4 requires.
Step 2: Create an Immutable Audit Log
Consent decisions change. ISO 27701 requires proving not just current state, but complete history. The key insight: never update consent records, always insert new ones.
public class ConsentAuditLog
{
public int Id { get; set; }
public string UserId { get; set; }
public ConsentPurpose Purpose { get; set; }
public bool WasGranted { get; set; } // Previous state
public bool IsGranted { get; set; } // New state
public DateTime ChangedAt { get; set; }
public string ChangeReason { get; set; } // User action, policy update, or expiration
public string PolicyVersion { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public ApplicationUser User { get; set; }
}
Every consent change (granted, withdrawn, expired) creates an audit entry. This log is append-only: no updates, no deletes. When an auditor asks “What did user X consent to on March 15, 2024, and under what policy version?” you have the answer.
Step 3: Build a Consent Service
Encapsulate consent logic in a service that enforces business rules. The interface is straightforward:
public interface IConsentService
{
Task<bool> HasValidConsentAsync(string userId, ConsentPurpose purpose);
Task GrantConsentAsync(string userId, ConsentPurpose purpose, string policyVersion,
DateTime? expiresAt = null, string? ipAddress = null, string? userAgent = null);
Task WithdrawConsentAsync(string userId, ConsentPurpose purpose, string reason);
Task<IEnumerable<UserConsent>> GetUserConsentsAsync(string userId);
Task ExpireStaleConsentsAsync();
}
The implementation enforces critical invariants. Here’s the consent validation (note the expiration check):
public async Task<bool> HasValidConsentAsync(string userId, ConsentPurpose purpose)
{
var consent = await _context.UserConsents
.Where(c => c.UserId == userId && c.Purpose == purpose)
.OrderByDescending(c => c.GrantedAt)
.FirstOrDefaultAsync();
if (consent == null || !consent.IsGranted)
return false;
// Expired consent is no consent
if (consent.ExpiresAt.HasValue && consent.ExpiresAt.Value < DateTime.UtcNow)
{
_logger.LogWarning("Consent {Purpose} for user {UserId} has expired", purpose, userId);
return false;
}
return true;
}
Granting consent creates both a record and an audit entry. Notice we capture the previous state for the audit trail:
public async Task GrantConsentAsync(string userId, ConsentPurpose purpose,
string policyVersion, DateTime? expiresAt = null,
string? ipAddress = null, string? userAgent = null)
{
var previousConsent = await _context.UserConsents
.Where(c => c.UserId == userId && c.Purpose == purpose)
.OrderByDescending(c => c.GrantedAt)
.FirstOrDefaultAsync();
_context.UserConsents.Add(new UserConsent
{
UserId = userId, Purpose = purpose, IsGranted = true,
GrantedAt = DateTime.UtcNow, ExpiresAt = expiresAt,
PolicyVersion = policyVersion, IpAddress = ipAddress, UserAgent = userAgent
});
_context.ConsentAuditLogs.Add(new ConsentAuditLog
{
UserId = userId, Purpose = purpose,
WasGranted = previousConsent?.IsGranted ?? false, IsGranted = true,
ChangedAt = DateTime.UtcNow,
ChangeReason = previousConsent == null ? "Initial consent" : "Consent renewed",
PolicyVersion = policyVersion, IpAddress = ipAddress, UserAgent = userAgent
});
await _context.SaveChangesAsync();
}
Withdrawal follows the same pattern. It doesn’t delete records, it creates a new “not granted” record:
public async Task WithdrawConsentAsync(string userId, ConsentPurpose purpose, string reason)
{
var currentConsent = await _context.UserConsents
.Where(c => c.UserId == userId && c.Purpose == purpose)
.OrderByDescending(c => c.GrantedAt)
.FirstOrDefaultAsync();
if (currentConsent == null) return;
_context.UserConsents.Add(new UserConsent
{
UserId = userId, Purpose = purpose, IsGranted = false,
GrantedAt = DateTime.UtcNow, PolicyVersion = currentConsent.PolicyVersion
});
_context.ConsentAuditLogs.Add(new ConsentAuditLog
{
UserId = userId, Purpose = purpose,
WasGranted = currentConsent.IsGranted, IsGranted = false,
ChangedAt = DateTime.UtcNow, ChangeReason = reason,
PolicyVersion = currentConsent.PolicyVersion
});
await _context.SaveChangesAsync();
_logger.LogInformation("Consent withdrawn: {Purpose} for user {UserId}", purpose, userId);
}
Every state change is logged. Nothing is deleted. The audit trail is complete.
Step 4: Enforce Consent with Middleware
Sprinkling consent checks across controller actions is error-prone and inconsistent. Use middleware to enforce consent requirements before requests reach your application logic:
public class ConsentValidationMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ConsentValidationMiddleware> _logger;
public ConsentValidationMiddleware(RequestDelegate next,
ILogger<ConsentValidationMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context,
IConsentService consentService, UserManager<ApplicationUser> userManager)
{
if (!context.User.Identity?.IsAuthenticated ?? true)
{
await _next(context);
return;
}
var user = await userManager.GetUserAsync(context.User);
if (user == null) { await _next(context); return; }
var hasTerms = await consentService.HasValidConsentAsync(user.Id, ConsentPurpose.TermsOfService);
var hasPrivacy = await consentService.HasValidConsentAsync(user.Id, ConsentPurpose.PrivacyPolicy);
if (!hasTerms || !hasPrivacy)
{
_logger.LogWarning("User {UserId} missing required consent", user.Id);
context.Response.Redirect("/Account/ConsentRequired");
return;
}
await _next(context);
}
}
// Program.cs registration
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<ConsentValidationMiddleware>();
Users without valid required consents get redirected to update their preferences. No controller action ever executes without consent validation passing first.
Step 5: Build a User-Facing Consent Dashboard
ISO 27701 7.3.4 requires giving users a mechanism to modify or withdraw consent. Build a dashboard where users see exactly what they’ve agreed to and can change their minds:
public class ConsentManagementModel : PageModel
{
private readonly IConsentService _consentService;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IConfiguration _configuration;
public IEnumerable<ConsentViewModel> Consents { get; set; }
public async Task OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
var consents = await _consentService.GetUserConsentsAsync(user.Id);
var policyVersion = _configuration["ConsentManagement:PolicyVersion"];
Consents = new[]
{
BuildConsent(ConsentPurpose.TermsOfService, "Terms of Service",
"Required to use the platform", true, consents, policyVersion),
BuildConsent(ConsentPurpose.MarketingEmails, "Marketing Communications",
"Receive promotional emails and product updates", false, consents, policyVersion),
BuildConsent(ConsentPurpose.AnalyticsTracking, "Analytics & Performance",
"Help us improve with usage analytics", false, consents, policyVersion),
BuildConsent(ConsentPurpose.ThirdPartyDataSharing, "Third-Party Data Sharing",
"Share anonymized data with trusted partners", false, consents, policyVersion)
};
}
public async Task<IActionResult> OnPostAsync(Dictionary<string, bool> consentChoices)
{
var user = await _userManager.GetUserAsync(User);
var policyVersion = _configuration["ConsentManagement:PolicyVersion"];
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers["User-Agent"].ToString();
foreach (var (key, granted) in consentChoices)
{
if (!Enum.TryParse<ConsentPurpose>(key, out var purpose)) continue;
if (granted)
{
// Marketing consent expires after 2 years
var expiresAt = purpose == ConsentPurpose.MarketingEmails
? DateTime.UtcNow.AddYears(2) : (DateTime?)null;
await _consentService.GrantConsentAsync(user.Id, purpose,
policyVersion, expiresAt, ipAddress, userAgent);
}
else
{
await _consentService.WithdrawConsentAsync(user.Id, purpose,
"User preference update");
}
}
return RedirectToPage();
}
private static ConsentViewModel BuildConsent(ConsentPurpose purpose, string name,
string desc, bool required, IEnumerable<UserConsent> consents, string version)
=> new() { Purpose = purpose, DisplayName = name, Description = desc,
IsRequired = required, PolicyVersion = version,
IsGranted = consents.FirstOrDefault(c => c.Purpose == purpose)?.IsGranted ?? false };
}
Users see exactly what they’ve consented to, when it expires, and can toggle optional consents freely. Required consents (Terms, Privacy) can only be withdrawn by closing the account.
Step 6: Automate Consent Expiration with Azure Functions
Consent shouldn’t live forever. Use Azure Functions to automatically expire stale consents:
public class ConsentExpirationFunction
{
private readonly IConsentService _consentService;
private readonly ILogger<ConsentExpirationFunction> _logger;
public ConsentExpirationFunction(IConsentService consentService,
ILogger<ConsentExpirationFunction> logger)
{
_consentService = consentService;
_logger = logger;
}
[FunctionName("ExpireStaleConsents")]
public async Task Run([TimerTrigger("0 0 2 * * *")] TimerInfo timer) // Daily at 2 AM
{
_logger.LogInformation("Starting consent expiration check");
await _consentService.ExpireStaleConsentsAsync();
// Optional: Send renewal reminders for consents expiring within 30 days
}
}
This function runs daily, automatically expiring consents past their validity period and creating audit entries for each expiration. Users can be notified to renew consents before they lapse.
Step 7: Respect Consent in Application Insights
When a user withdraws analytics consent, your telemetry system must honor that choice immediately. Configure Application Insights to check consent before logging.
The challenge: ITelemetryInitializer.Initialize() is synchronous, but our consent service is async. Using .Result or .GetAwaiter().GetResult() risks deadlocks. Instead, cache consent status in the HttpContext.Items during the request pipeline:
// Middleware to cache consent status early in the pipeline
public class ConsentCachingMiddleware
{
private readonly RequestDelegate _next;
public ConsentCachingMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context, IConsentService consentService)
{
if (context.User.Identity?.IsAuthenticated == true)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
var hasAnalyticsConsent = await consentService
.HasValidConsentAsync(userId, ConsentPurpose.AnalyticsTracking);
context.Items["HasAnalyticsConsent"] = hasAnalyticsConsent;
}
await _next(context);
}
}
// Telemetry initializer reads from cache (no async needed)
public class ConsentAwareTelemetryInitializer : ITelemetryInitializer
{
private readonly IHttpContextAccessor _httpContextAccessor;
public ConsentAwareTelemetryInitializer(IHttpContextAccessor httpContextAccessor)
=> _httpContextAccessor = httpContextAccessor;
public void Initialize(ITelemetry telemetry)
{
var context = _httpContextAccessor.HttpContext;
if (context?.User?.Identity?.IsAuthenticated != true) return;
var hasConsent = context.Items["HasAnalyticsConsent"] as bool? ?? false;
if (!hasConsent && telemetry is ISupportProperties propTelemetry)
{
propTelemetry.Properties["UserId"] = "anonymous";
}
}
}
// Program.cs: Register middleware before telemetry initializer runs
app.UseAuthentication();
app.UseMiddleware<ConsentCachingMiddleware>();
builder.Services.AddApplicationInsightsTelemetry();
builder.Services.AddSingleton<ITelemetryInitializer, ConsentAwareTelemetryInitializer>();
Your analytics now respects user consent choices in real time. No consent, no tracking. As it should be.
Database Migration: Setting Up the Schema
Add the consent tables to your Entity Framework Core context:
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
public DbSet<UserConsent> UserConsents { get; set; }
public DbSet<ConsentAuditLog> ConsentAuditLogs { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<UserConsent>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.UserId, e.Purpose, e.GrantedAt });
entity.Property(e => e.PolicyVersion).HasMaxLength(50).IsRequired();
entity.Property(e => e.IpAddress).HasMaxLength(45); // IPv6 length
entity.HasOne(e => e.User).WithMany()
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<ConsentAuditLog>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.UserId, e.ChangedAt });
entity.Property(e => e.ChangeReason).HasMaxLength(500).IsRequired();
entity.Property(e => e.PolicyVersion).HasMaxLength(50).IsRequired();
entity.HasOne(e => e.User).WithMany()
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade);
});
}
}
Then create and apply the migration:
dotnet ef migrations add AddConsentManagement
dotnet ef database update
What This Actually Achieves
This implementation satisfies ISO 27701 Control 7.3.2 and GDPR Article 7 requirements:
Specific and granular. Each consent purpose is tracked separately. Users can consent to marketing emails without consenting to third-party data sharing. Withdrawing one leaves others intact.
Informed. The dashboard clearly describes what each consent enables, with policy version references so users know exactly what they’re agreeing to.
Freely given. Optional consents can be granted or withdrawn independently. There’s no “consent to everything or leave” coercion.
Provable. The immutable audit log provides complete history of every consent decision: timestamps, policy versions, IP addresses, user agents. When an auditor asks what a user agreed to three years ago, you have the answer.
Revocable. Users withdraw consent through the dashboard, and the system enforces that withdrawal across all data processing. Analytics stops. Marketing emails stop. No manual intervention required.
Expiring. Consents expire automatically, requiring periodic re-confirmation. No more “perpetual consent” from a checkbox clicked in 2019.
This isn’t compliance theater. It’s a defensible system that respects user autonomy while protecting your organization from regulatory liability.
The Real Cost of Fake Consent
A cookie banner is not consent management. It’s a legal fiction that experienced auditors see through in seconds.
When GDPR enforcement actions land, they don’t penalize missing cookie banners. They penalize missing audit trails. They penalize organizations that can’t demonstrate what specific processing activities a user agreed to, when, under what policy version, and through what mechanism they can withdraw. They penalize dark patterns that make opting out harder than opting in.
I’ve watched teams scramble when auditors ask for consent records and discover their entire “consent management system” is a boolean column with no history. That discovery usually happens about 72 hours before the response deadline.
ISO 27701 Control 7.3.2 exists because privacy isn’t about checkboxes. It’s about data subject control. If your users can’t meaningfully choose what you do with their data, you’re not managing consent. You’re accumulating liability.
The implementation shown here isn’t perfect. Production systems need consent delegation for organizational accounts, consent transfer during mergers, consent handling for minors, and integration with data deletion workflows. But this foundation (purpose-specific records, immutable audit logs, validation middleware, expiration automation) gives you architecture that survives scrutiny.
Build consent management that respects your users’ agency. Build systems that produce defensible audit trails. And for the love of everything auditable, stop pretending a single checkbox satisfies ISO 27701.
That boolean column you’re calling “consent”? Replace it. Your users and your legal team will thank you.

Comments