0

12 min left

0% complete

Blog

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

·12 min read·

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:

  1. The account is soft-deleted, not hard-deleted.
  2. The user is logged out and locked out.
  3. If they log back in within a configurable grace period, their account is fully restored with all data intact.
  4. 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

  1. 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

  1. User deletes account → set to inactive, timestamp recorded
  2. User comes back within 90 days
  3. They log in with their email and password
  4. System detects active grace period → reactivates the account automatically
  5. All data is preserved

Scenario B: Account Anonymized After Grace Period

  1. User deletes account
  2. They don’t return for 90+ days
  3. 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

  1. Original email is now free to register again

Scenario C: Re-registering After Anonymization

  1. Previous account was anonymized
  2. User tries to log in → fails
  3. User registers with the same email
  4. New account is created — clean slate
  5. Old anonymized record stays in DB for referential integrity

This ensures seamless re-onboarding without compromising history or consistency.


Trade-offs and Design Decisions

BenefitTrade-off
Users can recover accidentally deleted accountsSlightly more complex logic and state management
Foreign key integrity preservedDeleted user records remain in the DB
GDPR-compliant right to erasureRequires reliable background jobs
Email reuse enabledMust 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

CategoryActionReason
Auth tokensDELETESecurity
NotificationsDELETEPersonal data
Cart itemsDELETEPersonal data
AnalyticsSET UserId = NULLPreserve aggregate insights
User contentKEEP, show "Deleted User"Preserve community content
Audit logsKEEPLegal 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 = @id

Always 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 deactivated

Security:

  • 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://deleted

Page shows the form and waits for email confirmation.

Step 3: Confirm & Delete

User types email → server:

  1. Looks up code in DB
  2. Verifies ExpiresAtUtc > now and Used == false
  3. Verifies hash matches
  4. Verifies email matches the user
  5. 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

ItemTesting ValueProduction Value
Grace period5 minutes90 days (129600 minutes)
Cleanup job frequencyEvery 2 minutesDaily at 3 AM
Time checksTotalMinutesTotalDays
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

  1. Anonymize, don’t delete — keep IDs for referential integrity
  2. Use a grace period — let users recover without effort
  3. Anonymize on-demand — don’t rely solely on background jobs
  4. Use raw SQL for bulk ops — avoid EF tracking issues
  5. Centralize config — avoid magic numbers in code and UI
  6. Bypass query filters carefully — use IgnoreQueryFilters() when needed
  7. 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.

Test Your Understanding
Write what you learned and get AI-powered feedback

Contents