12 min left
0% complete
Secure Account Deletion with Soft-Delete and Grace Period Recovery
Deleting a user account is more than just removing data—it’s about balancing user experience, data integrity, and compliance with privacy laws like GDPR. A poorly implemented deletion flow can lead to
Deleting a user account is more than just removing data—it’s about balancing user experience, data integrity, and compliance with privacy laws like GDPR. A poorly implemented deletion flow can lead to data loss, broken content references, or even security vulnerabilities.
In this guide, we’ll walk through a production-ready soft-delete strategy with a grace period, allowing users to recover their accounts while ensuring true data anonymization after expiration. We’ll also cover how to prevent unauthorized deletions, even if someone gains access to a deletion link.
By the end, you’ll have everything you need to implement a secure, compliant, and user-friendly account deletion system that works across web and mobile platforms.
How It Works: The Big Picture
When a user deletes their account:
- The account is soft-deleted, not hard-deleted.
- The user is logged out and locked out.
- If they log back in within a configurable grace period, their account is fully restored with all data intact.
- After the grace period expires, the account is anonymized:
- Personal identifiers (email, name, phone) are removed or changed
- Password is cleared
- Original email becomes available for reuse
- The user record remains in the database to preserve foreign key relationships (e.g., for authored content).
This approach gives users peace of mind while maintaining system stability and compliance.
User Journey: What Happens When You Delete?
Scenario A: Recovery Within the Grace Period
- User deletes account → set to inactive, timestamp recorded
- User comes back within 90 days
- They log in with their email and password
- System detects active grace period → reactivates the account automatically
- All data is preserved
Scenario B: Account Anonymized After Grace Period
- User deletes account
- They don’t return for 90+ days
- On next login attempt or during background cleanup, the system anonymizes the account:
- Email becomes deleted_xxx@anonymous.local
- Name becomes "Deleted User"
- Account permanently locked
- Original email is now free to register again
Scenario C: Re-registering After Anonymization
- Previous account was anonymized
- User tries to log in → fails
- User registers with the same email
- New account is created — clean slate
- Old anonymized record stays in DB for referential integrity
This ensures seamless re-onboarding without compromising history or consistency.
Trade-offs and Design Decisions
| Benefit | Trade-off |
|---|---|
| Users can recover accidentally deleted accounts | Slightly more complex logic and state management |
| Foreign key integrity preserved | Deleted user records remain in the DB |
| GDPR-compliant right to erasure | Requires reliable background jobs |
| Email reuse enabled | Must prevent race conditions during re-registration |
| Cross-platform support (Web, Mobile, API) | Needs authentication-aware workflows |
We chose anonymization over deletion because deleting users often breaks referential constraints—for example, comments, posts, or audit logs linked to the user. By anonymizing instead, we protect personal data while keeping the platform stable.
We also use a configurable grace period (e.g., 5 minutes in dev, 90 days in production), and we centralize this value to avoid inconsistencies across code and UI.
Core Implementation
Let’s dive into the actual implementation.
1. User Model Changes
We add two key fields to track account status:
public class User
{
public bool IsActive { get; set; } = true;
/// <summary>
/// UTC timestamp when account was deactivated.
/// Null if active. Used for grace period calculation.
/// </summary>
public DateTime? InActiveAtUtc { get; set; }
}These fields work with global query filters (IsActive = true) to hide inactive users by default.
2. UserManager Logic
Here’s the full logic for handling deactivation, reactivation, and anonymization.
Constants and Text Helpers
Centralize the grace period to avoid scattered magic values:
public class CustomUserManager : UserManager<User>
{
// Single source of truth for grace period
public const int AccountGracePeriodMinutes = 5; // Use 129600 (90 days) in prod
public static string GracePeriodTextEnglish => $"{AccountGracePeriodMinutes} minutes";
public static string GracePeriodTextArabic => $"{AccountGracePeriodMinutes} دقائق";
}Use {0} placeholders in resource files and format at runtime:
@string.Format(Resources.RecoveryText, CustomUserManager.GracePeriodTextEnglish)Deactivate Account (Soft-Delete)
public async Task<Result> DeactivateAccountAsync(User user)
{
user.IsActive = false;
user.InActiveAtUtc = DateTime.UtcNow;
return await UpdateAsync(user);
}This immediately logs out the user and prevents new logins.
Reactivation via Email (e.g., During Registration)
Before allowing re-registration, check if the email belongs to a recently deleted account:
public async Task<User> TryReactivateByEmailAsync(string email)
{
var inactiveUser = await Users
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => !u.IsActive && u.NormalizedEmail == email.ToUpper());
if (inactiveUser == null) return null;
if (CanReactivate(inactiveUser))
return await ReactivateUserAsync(inactiveUser);
// Grace period expired → anonymize now
await AnonymizeExpiredAccountAsync(inactiveUser);
return null; // Allows fresh registration
}🔥 Critical Detail: We anonymize on demand, not just in background jobs.
This prevents “email already taken” errors after deletion but before cleanup.
Reactivation via Password (Login Flow)
Same idea, but requires valid credentials:
public async Task<User> TryReactivateWithPasswordAsync(string email, string password)
{
var inactiveUser = await Users
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => !u.IsActive && u.NormalizedEmail == email.ToUpper());
if (inactiveUser == null || !CanReactivate(inactiveUser))
return null;
if (!await CheckPasswordAsync(inactiveUser, password))
return null;
return await ReactivateUserAsync(inactiveUser);
}Only users with correct passwords can reactivate—no guessing allowed.
Can the User Reactivate?
public bool CanReactivate(User user)
{
if (user == null || user.IsActive) return false;
// Already anonymized
if (user.Email?.StartsWith("deleted_") == true) return false;
// Legacy case: no timestamp
if (!user.InActiveAtUtc.HasValue) return true;
var elapsed = (DateTime.UtcNow - user.InActiveAtUtc.Value).TotalMinutes;
return elapsed <= AccountGracePeriodMinutes;
}This determines recovery eligibility based on time elapsed.
Anonymize Expired Account
private async Task AnonymizeExpiredAccountAsync(User user)
{
var anonymousEmail = $"deleted_{Guid.NewGuid():N}@anonymous.local";
user.Email = anonymousEmail;
user.NormalizedEmail = anonymousEmail.ToUpperInvariant();
user.UserName = anonymousEmail;
user.PasswordHash = null;
user.PhoneNumber = null;
user.LockoutEnabled = true;
user.LockoutEnd = DateTimeOffset.MaxValue;
await UpdateAsync(user);
}We use @anonymous.local because .local is a reserved TLD and cannot exist on the public internet—safe for anonymization.
3. Background Cleanup Job
Run daily to catch any expired accounts missed during user flows:
var expiredAccounts = await dbContext.Users
.IgnoreQueryFilters()
.AsNoTracking()
.Where(u => !u.IsActive
&& u.InActiveAtUtc != null
&& !u.Email.EndsWith("@anonymous.local"))
.Where(u => EF.Functions.DateDiffMinute(u.InActiveAtUtc, DateTime.UtcNow) > AccountGracePeriodMinutes)
.Select(u => new { u.Id, u.Email })
.ToListAsync();
foreach (var account in expiredAccounts)
{
var anonymousEmail = $"deleted_{Guid.NewGuid():N}@anonymous.local";
await dbContext.Database.ExecuteSqlInterpolatedAsync($@"
UPDATE Users SET
Email = {anonymousEmail},
NormalizedEmail = {anonymousEmail.ToUpperInvariant()},
PasswordHash = NULL,
PhoneNumber = NULL,
LockoutEnabled = 1,
LockoutEnd = '9999-12-31'
WHERE Id = {account.Id}");
}Why raw SQL? Avoids EF tracking issues in batch operations.
Data Handling Strategy
| Category | Action | Reason |
|---|---|---|
| Auth tokens | DELETE | Security |
| Notifications | DELETE | Personal data |
| Cart items | DELETE | Personal data |
| Analytics | SET UserId = NULL | Preserve aggregate insights |
| User content | KEEP, show "Deleted User" | Preserve community content |
| Audit logs | KEEP | Legal compliance |
We never hard-delete user records. Instead, we anonymize—a safer, more compliant alternative.
Solving Common Pitfalls
❌ Problem: Foreign Key Constraints Break Hard Deletes
Solution: Don’t delete—anonymize. Keep the ID, remove PII.
❌ Problem: [NotMapped] Properties Cause SQL Errors
If Name is stored in a UserProfiles table and marked [NotMapped], raw SQL on Users will fail.
Fix: Update the correct table:
UPDATE UserProfiles SET Name = 'Deleted User' WHERE UserId = @idAlways verify where data lives before writing raw queries.
❌ Problem: Cleanup Job Runs Forever
Without filtering, it reprocesses already-anonymized accounts.
Fix: Skip anonymized users:
.Where(u => !u.Email.EndsWith("@anonymous.local"))❌ Problem: EF Tracking Conflicts in Jobs
Solution: Use raw SQL for bulk updates. EF doesn’t need to track entities.
❌ Problem: “Email Already Taken” After Deletion
User deletes → waits 91 days → tries to re-register → blocked.
Root cause: Cleanup job hasn’t run yet.
Fix: Anonymize during registration attempt—don’t wait.
We already implemented this in TryReactivateByEmailAsync().
❌ Problem: Can’t Find Inactive Users Due to Query Filters
Global query filters (Where(u => u.IsActive)) hide inactive users.
Fix: Use IgnoreQueryFilters() when you need to bypass:
var user = await Users.IgnoreQueryFilters().FirstOrDefaultAsync(...);❌ Problem: Hardcoded Grace Periods Everywhere
Solution: One constant, used across backend and frontend via formatted placeholders.
🔐 Ensuring Only the User Can Delete Their Account
Even if someone gets the deletion link, they cannot delete the account without additional verification.
We use two secure flows depending on the platform.
Flow 1: Web (Session-Based)
For authenticated web users:
User clicks "Delete Account"
→ Server verifies User.Identity.IsAuthenticated
→ Gets user ID from session (tamper-proof)
→ User types their email to confirm
→ Account deactivatedSecurity:
- Session cookie required → attacker can’t fake identity
- Email confirmation → prevents accidental clicks
Flow 2: Mobile (JWT + One-Time Code)
Mobile apps use JWT tokens—no session cookies. So we use a short-lived, cryptographically secure one-time deletion code.
Step 1: Request Deletion Code (Requires JWT)
POST /api/v1/account/request-deletion
Authorization: Bearer <valid-jwt>Server-side:
var deletionRequest = new AccountDeletionRequest
{
UserId = currentUser.Id,
CodeHash = Sha256.Hash(deletionCode), // Store hash only
ExpiresAtUtc = DateTime.UtcNow.AddMinutes(10),
Used = false
};- Code is 32 random bytes → 256-bit entropy
- Hashed before storing → even DB breach won’t reveal codes
- 10-minute expiry → limits attack window
- One-time use → invalidated after deletion
Step 2: Open Delete Page in WebView
GET /en/account/delete?code=abc123...&returnTo=myapp://deletedPage shows the form and waits for email confirmation.
Step 3: Confirm & Delete
User types email → server:
- Looks up code in DB
- Verifies
ExpiresAtUtc > nowandUsed == false - Verifies hash matches
- Verifies email matches the user
- Proceeds with deletion
Only then is the account deactivated.
✅ Security Guarantee: What If the Link Is Shared?
Even if someone gets the link:
- They don’t know the user’s email → can’t confirm deletion
- Code is hashed and single-use → can’t guess or reuse
- Code expires in 10 minutes → useless after that
- User ID is tied to code → no cross-user attacks
Snippet: How we ensure only the user can delete, even with the link
public async Task<bool> ValidateAndDeleteAsync(string deletionCode, string typedEmail)
{
var codeHash = Sha256.Hash(deletionCode);
var request = await dbContext.DeletionRequests
.FirstOrDefaultAsync(r => r.CodeHash == codeHash);
if (request == null || request.Used || request.ExpiresAtUtc < DateTime.UtcNow)
return false;
var user = await userManager.FindByIdAsync(request.UserId.ToString());
if (user?.Email.ToLower() != typedEmail.ToLower())
return false; // Email mismatch
await userManager.DeactivateAccountAsync(user);
request.Used = true;
await dbContext.SaveChangesAsync();
return true;
}This ensures that possession of the link is not enough—you must also:
- Know the associated email
- Submit within 10 minutes
- Not have already used the code
Mobile Integration Example (React Native / Flutter)
async function requestAccountDeletion() {
const response = await fetch('/api/v1/account/request-deletion', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) throw new Error('Failed to request deletion');
return response.json(); // { deletionCode, expiresAt }
}
function openDeletionPage(deletionCode: string) {
const url = `https://your-domain.com/en/account/delete?code=${deletionCode}&returnTo=myapp://deleted`;
openWebView(url);
}
function handleDeepLink(url: string) {
if (url === 'myapp://account-deleted') {
clearAuthTokens();
showMessage('Account deleted successfully');
}
}Provide multiple aliases (returnTo, redirect, next) for compatibility.
Production Readiness Checklist
| Item | Testing Value | Production Value |
|---|---|---|
| Grace period | 5 minutes | 90 days (129600 minutes) |
| Cleanup job frequency | Every 2 minutes | Daily at 3 AM |
| Time checks | TotalMinutes | TotalDays |
| Grace period text | "5 minutes" | "90 days" or "3 months" |
Test with short durations, then scale up.
Final Thoughts
Implementing account deletion isn’t just about removing data—it’s about doing it securely, safely, and reversibly.
Key Takeaways
- Anonymize, don’t delete — keep IDs for referential integrity
- Use a grace period — let users recover without effort
- Anonymize on-demand — don’t rely solely on background jobs
- Use raw SQL for bulk ops — avoid EF tracking issues
- Centralize config — avoid magic numbers in code and UI
- Bypass query filters carefully — use
IgnoreQueryFilters()when needed - Secure deletion flows — never allow deletion by link alone
With this pattern, you deliver a user-friendly, compliant, and robust account deletion experience—across web, mobile, and API platforms.
Now go build it with confidence.