Purpose Limitation in API Design: Leaking Data You Shouldn’t
Your password reset endpoint probably returns the user’s full profile, purchase history, and marketing preferences. The caller asked for an email address. You gave them everything.
That’s purpose drift: exposing data beyond what the caller needs for its stated function. GDPR Article 5(1)(b) and ISO/IEC 27701 both require that personal data be processed only for the purpose it was collected. This isn’t a compliance checkbox. It’s an architectural constraint that determines how you structure API endpoints, implement authorization policies, and design response payloads.
Most .NET applications fail this test because they design APIs around database entities rather than caller purposes. The typical GetUser endpoint returns everything the database contains, regardless of why the caller requested it.
The Fatal Pattern: Entity-Centric API Design
The typical ASP.NET Core API exposes personal data through broad entity endpoints:
[HttpGet("{id}")]
[Authorize]
public async Task<ActionResult<UserDto>> GetUser(int id)
{
var user = await _context.Users
.Include(u => u.Profile)
.Include(u => u.Addresses)
.Include(u => u.PaymentMethods)
.Include(u => u.OrderHistory)
.FirstOrDefaultAsync(u => u.Id == id);
if (user == null)
return NotFound();
// Any authenticated user can retrieve full data for any user ID
return Ok(MapToDto(user));
}
This pattern violates purpose limitation in multiple ways:
Over-exposure: A dashboard widget showing “Welcome, John” receives the same payload as an order fulfillment system processing a shipment. The caller’s purpose doesn’t affect what data they receive.
No access controls: Any authenticated user can retrieve full profile data for any user ID by incrementing the endpoint parameter. Authentication proves identity, but nothing validates purpose.
Invisible data flows: The API documentation describes endpoints but doesn’t specify why each field is returned. Developers consuming the API cannot determine whether their intended use complies with the original collection purpose.
From a .NET implementation perspective, this design treats User as a data structure rather than a protected resource with context-dependent visibility.
Purpose-Specific Endpoint Design
Effective purpose limitation requires restructuring APIs around caller purposes rather than database entities:
[ApiController]
[Route("api/users")]
public class UserProfileController : ControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IUserService _userService;
// Purpose: Display user greeting in application header
// Data Minimization: First name only
[HttpGet("{id}/greeting")]
[Authorize]
public async Task<ActionResult<UserGreetingDto>> GetUserGreeting(int id)
{
var user = await _userService.GetUserByIdAsync(id);
if (user == null)
return NotFound();
var authResult = await _authorizationService
.AuthorizeAsync(User, user, "ViewOwnProfile");
if (!authResult.Succeeded)
return Forbid();
return Ok(new UserGreetingDto { FirstName = user.FirstName });
}
// Purpose: Account settings management
// Data Minimization: Profile fields only, excludes order/payment history
[HttpGet("{id}/profile-settings")]
[Authorize]
public async Task<ActionResult<ProfileSettingsDto>> GetProfileSettings(int id)
{
var user = await _userService.GetUserByIdAsync(id);
if (user == null)
return NotFound();
var authResult = await _authorizationService
.AuthorizeAsync(User, user, "ManageOwnProfile");
if (!authResult.Succeeded)
return Forbid();
return Ok(new ProfileSettingsDto
{
Email = user.Email,
FirstName = user.FirstName,
LastName = user.LastName,
PhoneNumber = user.PhoneNumber
});
}
}
The key difference: each endpoint documents its purpose and returns only the fields needed for that purpose. The greeting endpoint returns a first name. The profile settings endpoint returns contact information. Neither returns payment methods or order history because those fields serve different purposes.
Resource-Based Authorization for Field-Level Control
ASP.NET Core’s resource-based authorization enables purpose-aware access control by evaluating both the user’s identity and the specific resource being accessed:
// Authorization requirement that validates purpose context
public class PurposeLimitationRequirement : IAuthorizationRequirement
{
public DataProcessingPurpose Purpose { get; }
public PurposeLimitationRequirement(DataProcessingPurpose purpose)
{
Purpose = purpose;
}
}
public enum DataProcessingPurpose
{
AccountManagement,
OrderFulfillment,
CustomerSupport,
MarketingAnalytics,
SecurityMonitoring
}
// Authorization handler that checks consent for purpose
public class PurposeLimitationHandler
: AuthorizationHandler<PurposeLimitationRequirement, User>
{
private readonly IConsentService _consentService;
public PurposeLimitationHandler(IConsentService consentService)
{
_consentService = consentService;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PurposeLimitationRequirement requirement,
User resource)
{
var userIdClaim = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userIdClaim == null || !int.TryParse(userIdClaim, out var userId))
{
// Invalid or missing user identifier - deny access
return;
}
// Users can always access their own data for account management
if (requirement.Purpose == DataProcessingPurpose.AccountManagement
&& userId == resource.Id)
{
context.Succeed(requirement);
return;
}
// For other purposes, verify explicit consent
var hasConsent = await _consentService.HasConsentAsync(
resource.Id,
ConsentPurposeFromDataProcessingPurpose(requirement.Purpose));
if (hasConsent)
{
context.Succeed(requirement);
}
}
private ConsentPurpose ConsentPurposeFromDataProcessingPurpose(
DataProcessingPurpose purpose)
{
return purpose switch
{
DataProcessingPurpose.MarketingAnalytics => ConsentPurpose.MarketingAnalytics,
DataProcessingPurpose.CustomerSupport => ConsentPurpose.CustomerSupport,
DataProcessingPurpose.SecurityMonitoring => ConsentPurpose.SecurityMonitoring,
_ => throw new InvalidOperationException($"No consent mapping for {purpose}")
};
}
}
// Registration in Program.cs
builder.Services.AddScoped<IAuthorizationHandler, PurposeLimitationHandler>();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AccountManagement", policy =>
policy.Requirements.Add(
new PurposeLimitationRequirement(DataProcessingPurpose.AccountManagement)));
options.AddPolicy("MarketingAnalytics", policy =>
policy.Requirements.Add(
new PurposeLimitationRequirement(DataProcessingPurpose.MarketingAnalytics)));
});
This authorization handler enforces purpose limitation at the framework level. An endpoint attempting to access user data for marketing analytics will be denied unless the user explicitly consented to that purpose—even if the same data would be accessible for account management purposes.
Field-Level Authorization with GraphQL
GraphQL APIs pose unique purpose limitation challenges because clients construct queries dynamically, requesting arbitrary field combinations. A query for { user { email } } serves a different purpose than { user { email orderHistory { items total } } }, but traditional authorization sees both as “get user” requests.
HotChocolate, the most comprehensive .NET GraphQL server, supports field-level authorization that enforces purpose limitation for each requested field:
public class UserType : ObjectType<User>
{
protected override void Configure(IObjectTypeDescriptor<User> descriptor)
{
descriptor.Field(u => u.Id)
.Authorize(); // Basic authentication required
descriptor.Field(u => u.Email)
.Authorize("AccountManagement"); // Purpose: account functionality
descriptor.Field(u => u.FirstName)
.Authorize("AccountManagement");
descriptor.Field(u => u.BirthDate)
.Authorize("MarketingAnalytics"); // Purpose: demographic analysis
descriptor.Field(u => u.OrderHistory)
.Authorize("OrderFulfillment"); // Purpose: order processing
descriptor.Field(u => u.PaymentMethods)
.Authorize("PaymentProcessing"); // Purpose: payment handling
}
}
// GraphQL query with field-level validation
// Query: { user(id: 123) { email birthDate orderHistory { total } } }
// Result: Returns email (account management), denies birthDate (no marketing consent),
// denies orderHistory (no order fulfillment permission)
{
"data": {
"user": {
"email": "john@example.com",
"birthDate": null, // Field denied - insufficient consent
"orderHistory": null // Field denied - insufficient permission
}
},
"errors": [
{
"message": "The current user is not authorized to access this resource.",
"path": ["user", "birthDate"]
},
{
"message": "The current user is not authorized to access this resource.",
"path": ["user", "orderHistory"]
}
]
}
Each field specifies the processing purpose required to access it. A user query for demographic analysis receives birthDate only if marketing analytics consent exists. An order processing query receives orderHistory only if the caller has order fulfillment permissions. The same user resource returns different field sets depending on the caller’s stated purpose.
Consent-Aware Middleware for Audit Context
Purpose limitation requires not just access control but audit trails documenting why data was accessed. Custom middleware captures the processing purpose for every request:
public class PurposeAuditMiddleware
{
private readonly RequestDelegate _next;
private readonly IAuditLogger _auditLogger;
public PurposeAuditMiddleware(RequestDelegate next, IAuditLogger auditLogger)
{
_next = next;
_auditLogger = auditLogger;
}
public async Task InvokeAsync(HttpContext context)
{
// Extract purpose from route or header
// Header approach allows clients to explicitly declare purpose
// Route-based fallback provides default behavior
var purpose = context.Request.Headers["X-Processing-Purpose"].FirstOrDefault()
?? DeterminePurposeFromRoute(context.Request.Path);
// Store in HttpContext for downstream authorization
context.Items["ProcessingPurpose"] = purpose;
// Capture audit data before request
var auditEntry = new DataAccessAuditEntry
{
Timestamp = DateTime.UtcNow,
UserId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value,
TargetUserId = ExtractTargetUserIdFromPath(context.Request.Path),
Endpoint = context.Request.Path,
Purpose = purpose,
IpAddress = context.Connection.RemoteIpAddress?.ToString()
};
await _next(context);
// Log completed request with purpose context
auditEntry.StatusCode = context.Response.StatusCode;
await _auditLogger.LogAccessAsync(auditEntry);
}
private string DeterminePurposeFromRoute(PathString path)
{
if (path.StartsWithSegments("/api/users/greeting"))
return "AccountManagement";
if (path.StartsWithSegments("/api/users/shipping-addresses"))
return "OrderFulfillment";
if (path.StartsWithSegments("/api/analytics"))
return "MarketingAnalytics";
return "Unspecified";
}
private string ExtractTargetUserIdFromPath(PathString path)
{
// Extract user ID from path like /api/users/123/greeting
var segments = path.Value?.Split('/');
if (segments != null && segments.Length > 3 && segments[2] == "users")
{
return segments[3];
}
return null;
}
}
// Registration in Program.cs
app.UseMiddleware<PurposeAuditMiddleware>();
Every personal data access now includes the processing purpose in audit logs. When a GDPR Article 15 (Right of access) request arrives, you can generate a complete report showing every access to that user’s data, the stated purpose for each access, and whether those purposes align with original collection consent.
Monitoring Purpose Violations with Health Checks
ASP.NET Core health checks can detect purpose drift by monitoring unauthorized access patterns:
public class PurposeLimitationHealthCheck : IHealthCheck
{
private readonly IAuditLogRepository _auditRepository;
private readonly IConsentRepository _consentRepository;
public PurposeLimitationHealthCheck(
IAuditLogRepository auditRepository,
IConsentRepository consentRepository)
{
_auditRepository = auditRepository;
_consentRepository = consentRepository;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var last24Hours = DateTime.UtcNow.AddHours(-24);
// Find accesses where purpose doesn't match consent
var violations = await _auditRepository.GetAccessesAsync(
since: last24Hours,
filter: entry => entry.StatusCode == 200); // Successful accesses
var purposeViolations = new List<string>();
foreach (var access in violations)
{
if (access.Purpose == "Unspecified")
{
purposeViolations.Add(
$"Unspecified purpose: {access.Endpoint} by {access.UserId}");
continue;
}
var consent = await _consentRepository.GetConsentAsync(
access.TargetUserId,
ConsentPurposeFromString(access.Purpose));
if (consent == null || !consent.IsActive)
{
purposeViolations.Add(
$"No consent for {access.Purpose}: User {access.TargetUserId}, " +
$"Endpoint {access.Endpoint}");
}
}
if (purposeViolations.Count > 10)
{
return HealthCheckResult.Unhealthy(
$"Detected {purposeViolations.Count} purpose limitation violations in last 24h",
data: new Dictionary<string, object>
{
["ViolationCount"] = purposeViolations.Count,
["Violations"] = purposeViolations
});
}
if (purposeViolations.Any())
{
return HealthCheckResult.Degraded(
$"Detected {purposeViolations.Count} potential violations",
data: new Dictionary<string, object>
{
["Violations"] = purposeViolations
});
}
return HealthCheckResult.Healthy("No purpose limitation violations detected");
}
}
// Registration in Program.cs
builder.Services.AddHealthChecks()
.AddCheck<PurposeLimitationHealthCheck>(
"purpose-limitation",
failureStatus: HealthStatus.Degraded,
tags: new[] { "privacy", "compliance" });
This health check analyzes audit logs to identify data accesses without corresponding consent. If marketing analytics endpoints are being called for users who never consented to analytics, the health check fails, triggering alerts before a regulatory audit discovers the violation.
Practical Implementation: The Purpose-First API Design Checklist
Purpose limitation transforms from abstract compliance requirement to concrete engineering practice through systematic API design:
Before creating an endpoint:
- Document the specific processing purpose this endpoint serves
- Identify the minimum data fields required for that purpose
- Determine the legal basis (contract, legitimate interest, consent)
- If consent-based, verify consent validation logic exists
During implementation:
- Create purpose-specific DTOs that expose only required fields
- Implement resource-based authorization validating purpose permission
- Add audit logging capturing the processing purpose
- Document the purpose and legal basis in API specifications
After deployment:
- Monitor access logs for purpose/consent mismatches
- Review authorization failures for legitimate use cases requiring consent
- Audit endpoint usage patterns against original purpose documentation
- Update consent capture flows if new purposes are introduced
Beyond Compliance: Purpose as Architecture
ISO/IEC 27701 Control 7.2.6 (Limiting use, retention and disclosure) and GDPR Article 25 (Data protection by design) require purpose limitation not as a policy but as an architectural property. ASP.NET Core’s authorization framework, when applied to resources rather than just controllers, makes purpose-aware access control a natural extension of existing security patterns.
The fatal mistake is treating all personal data as uniformly accessible once a user authenticates. The correct approach recognizes that data access requires both identity verification and purpose validation. A user proving who they are establishes the first authorization layer. The system determining why they need specific data establishes the second layer, the one that regulations actually mandate.
Purpose limitation is not an add-on feature. It’s the recognition that every API endpoint represents a contract: the caller states a purpose, and the API returns only data necessary to fulfill that purpose. When your endpoints leak more data than required, you’re not just violating regulatory standards. You’re violating the fundamental contract between data subjects and the systems that process their information.

Comments