Skip to content

Opt-Out (CCPA)

The CCPA requires that users can opt out of data sale/sharing without creating an account or logging in. The opt-out system supports both authenticated users and anonymous visitors.

  1. POST /privacy/opt-out is [AllowAnonymous]
  2. If authenticated → opt-out recorded against UserId
  3. If anonymous → AnonymousTrackId generated, stored in _optout_id HTTP-Only cookie
  4. Idempotent — repeated requests return existing opt-out status

| Property | Value | | -------- | ----- | | Category | StrictlyNecessary (registered in ICookieRegistry) | | HttpOnly | true | | Secure | true | | SameSite | Lax | | Retention | 730 days (2 years) |

When a visitor logs in after opting out anonymously, the application should merge the anonymous opt-out with their user profile:

// In your login handler (e.g., Wolverine handler on UserLoggedInEto)
await optOutWriter.MergeAnonymousAsync(anonymousTrackId, userId);

The framework provides the IOptOutRecordWriter.MergeAnonymousAsync() interface; the merge call is application-level logic, not auto-triggered.

| Method | Route | Operation | Auth | | ------ | ----- | --------- | ---- | | POST | /privacy/opt-out | RequestOptOut | Anonymous | | GET | /privacy/opt-out/status | GetOptOutStatus | Anonymous |

Because POST /privacy/opt-out is anonymous, it is rate-limited via the framework-owned policy privacy-optout-create (wire the body under RateLimiting:Policies:privacy-optout-create; recommended: 5 requests/minute, partitioned by client IP for guests and by user id when authenticated). Exceeding it returns 429.

Provide a store backed by your own persistence and register it:

public sealed class OptOutRecordStore(AppDbContext db)
: IOptOutRecordReader, IOptOutRecordWriter
{
// IOptOutRecordReader
public Task<OptOutRecord?> GetByUserAsync(Guid userId, CancellationToken ct = default) => /* … */;
public Task<OptOutRecord?> GetByAnonymousTrackAsync(string trackId, CancellationToken ct = default) => /* … */;
public Task<bool> IsOptedOutAsync(Guid? userId, string? anonymousTrackId, CancellationToken ct = default) => /* … */;
// IOptOutRecordWriter
public Task RecordOptOutAsync(OptOutRecord record, CancellationToken ct = default) => /* … */;
public Task RevokeOptOutAsync(Guid recordId, DateTimeOffset revokedAt, CancellationToken ct = default) => /* … */;
public Task MergeAnonymousAsync(string anonymousTrackId, Guid userId, CancellationToken ct = default) => /* … */;
}
services.AddGranitPrivacy(privacy =>
privacy.UseOptOutRecordStore<OptOutRecordStore>());