Skip to content

CWE-352: Cross-Site Request Forgery (CSRF) - C#

Overview

CSRF vulnerabilities in C# web applications occur when state-changing endpoints don't verify that requests originated from the application itself. ASP.NET Core provides built-in anti-forgery token support, while legacy ASP.NET MVC requires explicit configuration. Both frameworks require proper implementation to prevent CSRF attacks.

Primary Defence: Use [ValidateAntiForgeryToken] attribute on state-changing actions (ASP.NET Core/MVC) or services.AddAntiforgery() with automatic token validation.

Common Vulnerable Patterns

ASP.NET Core without anti-forgery validation

Startup.cs or Program.cs
// VULNERABLE
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllersWithViews();
    // No anti-forgery configuration
}
AccountController.cs
// VULNERABLE
using Microsoft.AspNetCore.Mvc;

public class AccountController : Controller
{
    private readonly IUserService _userService;

    public AccountController(IUserService userService)
    {
        _userService = userService;
    }

    // VULNERABLE - No anti-forgery validation
    [HttpPost]
    public IActionResult UpdateEmail(string email)
    {
        var userId = HttpContext.Session.GetInt32("UserId");
        if (userId == null)
        {
            return Unauthorized();
        }

        // State change without CSRF protection
        _userService.UpdateEmail(userId.Value, email);
        return Ok(new { status = "updated" });
    }

    [HttpPost]
    public IActionResult DeleteAccount()
    {
        // VULNERABLE - Critical action without CSRF token
        var userId = HttpContext.Session.GetInt32("UserId");
        if (userId != null)
        {
            _userService.DeleteAccount(userId.Value);
            HttpContext.Session.Clear();
        }
        return Ok(new { status = "deleted" });
    }
}

Why this is vulnerable: Without anti-forgery tokens, attackers can craft malicious websites that submit authenticated requests to these endpoints using the victim's session cookies, allowing unauthorized email changes or account deletions.

ASP.NET Core API without CSRF protection

TransferController.cs
// VULNERABLE
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class TransferController : ControllerBase
{
    private readonly ITransferService _transferService;

    public TransferController(ITransferService transferService)
    {
        _transferService = transferService;
    }

    // VULNERABLE - Cookie authentication without CSRF token
    [HttpPost]
    public IActionResult Transfer([FromBody] TransferRequest request)
    {
        var userId = HttpContext.Session.GetInt32("UserId");
        if (userId == null)
        {
            return Unauthorized();
        }

        // No CSRF validation
        _transferService.Transfer(userId.Value, request.ToAccount, request.Amount);
        return Ok(new { status = "success" });
    }
}

Why this is vulnerable: APIs that use cookie-based authentication without CSRF tokens are vulnerable to cross-origin requests from malicious websites, allowing attackers to perform financial transfers or other sensitive operations using the victim's authenticated session.

Legacy ASP.NET MVC without ValidateAntiForgeryToken

AccountController.cs
// VULNERABLE
using System.Web.Mvc;

public class AccountController : Controller
{
    // VULNERABLE - Missing [ValidateAntiForgeryToken]
    [HttpPost]
    public ActionResult UpdateEmail(string email)
    {
        if (Session["UserId"] == null)
        {
            return new HttpUnauthorizedResult();
        }

        var userId = (int)Session["UserId"];
        _userService.UpdateEmail(userId, email);

        return Json(new { status = "updated" });
    }
}

Why this is vulnerable: Without the [ValidateAntiForgeryToken] attribute, this endpoint accepts any POST request with valid session cookies, allowing attackers to embed malicious forms in external websites that change user emails without user knowledge.

Secure Patterns

ASP.NET Core Razor Pages with anti-forgery

Program.cs or Startup.cs
// SECURE
using Microsoft.AspNetCore.Antiforgery;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();

// Configure anti-forgery
builder.Services.AddAntiforgery(options =>
{
    options.HeaderName = "X-CSRF-TOKEN";
    options.Cookie.Name = "CSRF-TOKEN";
    options.Cookie.SameSite = SameSiteMode.Strict;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.Cookie.HttpOnly = true;
});

// Configure session
builder.Services.AddSession(options =>
{
    options.Cookie.SameSite = SameSiteMode.Strict;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.Cookie.HttpOnly = true;
});

var app = builder.Build();

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseSession();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapRazorPages();

app.Run();
Pages/Transfer.cshtml.cs
// SECURE
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

public class TransferModel : PageModel
{
    private readonly ITransferService _transferService;

    public TransferModel(ITransferService transferService)
    {
        _transferService = transferService;
    }

    [BindProperty]
    public TransferRequest TransferData { get; set; }

    public void OnGet()
    {
        // Anti-forgery token automatically generated
    }

    // Anti-forgery validation automatic for Razor Pages POST
    public IActionResult OnPost()
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        var userId = HttpContext.Session.GetInt32("UserId");
        if (userId == null)
        {
            return Unauthorized();
        }

        // CSRF token already validated
        _transferService.Transfer(
            userId.Value, 
            TransferData.ToAccount, 
            TransferData.Amount
        );

        return RedirectToPage("Success");
    }
}
Pages/Transfer.cshtml
@page
@model TransferModel

<form method="post">
    <!-- Anti-forgery token automatically included -->
    <div>
        <label asp-for="TransferData.ToAccount"></label>
        <input asp-for="TransferData.ToAccount" />
    </div>
    <div>
        <label asp-for="TransferData.Amount"></label>
        <input asp-for="TransferData.Amount" type="number" step="0.01" />
    </div>
    <button type="submit">Transfer</button>
</form>

Why this works:

  • Zero-configuration: Automatically generates and validates tokens for all POST requests without explicit attributes
  • Synchronizer Token Pattern: Hidden field and cookie both contain cryptographic tokens (generated via RandomNumberGenerator); OnPost validates match using timing-safe comparison
  • Defense-in-depth: Cookie encrypted/signed with data protection keys; SameSite=Strict blocks cross-site transmission; tokens bound to user identity prevent cross-session replay
  • HTTPS enforcement: Cookie.SecurePolicy = CookieSecurePolicy.Always ensures HTTPS-only transmission
  • Framework-level protection: Eliminates most common CSRF vulnerability (developers forgetting to add protection)

ASP.NET Core MVC with ValidateAntiForgeryToken

// AccountController.cs - SECURE
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Antiforgery;

[AutoValidateAntiforgeryToken]  // Applies to all actions in controller
public class AccountController : Controller
{
    private readonly IUserService _userService;
    private readonly IAntiforgery _antiforgery;

    public AccountController(IUserService userService, IAntiforgery antiforgery)
    {
        _userService = userService;
        _antiforgery = antiforgery;
    }

    [HttpGet]
    public IActionResult UpdateEmail()
    {
        // Anti-forgery token automatically added to view
        return View();
    }

    [HttpPost]
    // [ValidateAntiForgeryToken] - Not needed, AutoValidateAntiforgeryToken on controller
    public IActionResult UpdateEmail(string email)
    {
        var userId = HttpContext.Session.GetInt32("UserId");
        if (userId == null)
        {
            return Unauthorized();
        }

        // Anti-forgery token automatically validated
        _userService.UpdateEmail(userId.Value, email);
        return RedirectToAction("Profile");
    }

    [HttpPost]
    public IActionResult DeleteAccount()
    {
        // Anti-forgery validation automatic
        var userId = HttpContext.Session.GetInt32("UserId");
        if (userId != null)
        {
            _userService.DeleteAccount(userId.Value);
            HttpContext.Session.Clear();
        }
        return RedirectToAction("Index", "Home");
    }
}

// Views/Account/UpdateEmail.cshtml
@{
    ViewData["Title"] = "Update Email";
}

<form asp-action="UpdateEmail" method="post">
    <!-- Anti-forgery token automatically included by asp-action tag helper -->
    <div>
        <label for="email">Email:</label>
        <input type="email" id="email" name="email" required />
    </div>
    <button type="submit">Update Email</button>
</form>

<!-- Alternative: Manual token inclusion -->
<form action="/Account/UpdateEmail" method="post">
    @Html.AntiForgeryToken()
    <input type="email" name="email" required />
    <button type="submit">Update Email</button>
</form>

Why this works:

  • Controller-level protection: [AutoValidateAntiforgeryToken] automatically validates all POST/PUT/PATCH/DELETE actions without per-method decoration, preventing forgotten endpoints
  • Cryptographic security: Generates tokens using RandomNumberGenerator with user identity binding; constant-time comparison (CryptographicOperations.FixedTimeEquals) prevents timing attacks
  • Flexible token generation: IAntiforgery service enables programmatic access for SPA endpoints; tag helpers (asp-action) auto-include hidden fields
  • Explicit opt-in: Unlike Razor Pages' implicit validation, MVC requires explicit attributes but controller-level provides comprehensive coverage
  • Balance: Automatic validation for framework forms with manual escape hatches when needed

ASP.NET Core API with anti-forgery

// TransferController.cs - SECURE
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Antiforgery;

[ApiController]
[Route("api/[controller]")]
public class TransferController : ControllerBase
{
    private readonly ITransferService _transferService;
    private readonly IAntiforgery _antiforgery;

    public TransferController(
        ITransferService transferService,
        IAntiforgery antiforgery)
    {
        _transferService = transferService;
        _antiforgery = antiforgery;
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public IActionResult Transfer([FromBody] TransferRequest request)
    {
        var userId = HttpContext.Session.GetInt32("UserId");
        if (userId == null)
        {
            return Unauthorized();
        }

        // Anti-forgery token validated by attribute
        _transferService.Transfer(userId.Value, request.ToAccount, request.Amount);
        return Ok(new { status = "success" });
    }

    [HttpGet("csrf-token")]
    public IActionResult GetCsrfToken()
    {
        // Provide CSRF token to JavaScript clients
        var tokens = _antiforgery.GetAndStoreTokens(HttpContext);
        return Ok(new { token = tokens.RequestToken });
    }
}

// JavaScript client code
/*
// Get CSRF token
async function getCsrfToken() {
    const response = await fetch('/api/transfer/csrf-token', {
        credentials: 'include'
    });
    const data = await response.json();
    return data.token;
}

// Make API call with CSRF token
async function transferFunds(toAccount, amount) {
    const csrfToken = await getCsrfToken();

    const response = await fetch('/api/transfer', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRF-TOKEN': csrfToken
        },
        credentials: 'include',
        body: JSON.stringify({
            toAccount: toAccount,
            amount: amount
        })
    });

    if (!response.ok) {
        throw new Error('Transfer failed');
    }

    return await response.json();
}
*/

Why this works:

  • Cookie-based auth vulnerability: Browsers auto-include cookies in cross-origin requests, requiring explicit anti-forgery validation for API endpoints
  • Dual-token pattern: Cookie stores token JavaScript can read (HttpOnly=false); validation checks X-CSRF-TOKEN header matches cookie - attackers cannot set custom headers cross-site
  • JavaScript integration: GetCsrfToken endpoint provides token; clients include in header with credentials: 'include' to send auth cookies
  • SPA and mobile support: Essential for applications using session-based auth without traditional form rendering
  • Header configuration: options.HeaderName = "X-CSRF-TOKEN" sets expected header name for validation

Global anti-forgery filter

// Program.cs - Apply anti-forgery globally
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews(options =>
{
    // Apply anti-forgery validation to all POST, PUT, DELETE, PATCH
    options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
});

// AccountController.cs
public class AccountController : Controller
{
    // Anti-forgery automatically validated for all POST actions
    [HttpPost]
    public IActionResult UpdateEmail(string email)
    {
        // CSRF token already validated by global filter
        _userService.UpdateEmail(userId, email);
        return Ok();
    }

    // Opt-out for specific endpoints (e.g., webhooks with different auth)
    [HttpPost]
    [IgnoreAntiforgeryToken]
    public IActionResult Webhook([FromBody] WebhookData data)
    {
        // Verify webhook signature instead
        if (!VerifyWebhookSignature(data))
        {
            return Unauthorized();
        }

        ProcessWebhook(data);
        return Ok();
    }
}

Why this works:

  • Secure by default: Global filter validates all POST/PUT/DELETE/PATCH requests automatically, preventing future endpoints from missing protection
  • Deny-by-default approach: More secure than opt-in; requires explicit [IgnoreAntiforgeryToken] for exceptions (visible in code reviews)
  • REST alignment: Validates state-changing methods only, allowing GET requests for read-only operations
  • Proper exceptions: Webhooks require [IgnoreAntiforgeryToken] with alternative auth (HMAC signatures) since external services can't provide tokens
  • Large application value: Reduces security burden across many controllers/teams; creates audit trail for any opt-outs

Legacy ASP.NET MVC with ValidateAntiForgeryToken

// Global.asax.cs or App_Start/FilterConfig.cs
public class FilterConfig
{
    public static void RegisterGlobalFilters(GlobalFilterCollection filters)
    {
        filters.Add(new HandleErrorAttribute());
        // Apply anti-forgery validation globally
        filters.Add(new ValidateAntiForgeryTokenAttribute());
    }
}

// Web.config - Configure cookies
<system.web>
    <httpCookies requireSSL="true" httpOnlyCookies="true" sameSite="Strict" />
    <sessionState cookieSameSite="Strict" />
</system.web>

// AccountController.cs - SECURE
using System.Web.Mvc;

public class AccountController : Controller
{
    [HttpGet]
    public ActionResult UpdateEmail()
    {
        return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult UpdateEmail(string email)
    {
        if (Session["UserId"] == null)
        {
            return new HttpUnauthorizedResult();
        }

        var userId = (int)Session["UserId"];
        _userService.UpdateEmail(userId, email);

        return Json(new { status = "updated" });
    }
}

// Views/Account/UpdateEmail.cshtml
@using (Html.BeginForm("UpdateEmail", "Account", FormMethod.Post))
{
    @Html.AntiForgeryToken()

    <div>
        <label for="email">Email:</label>
        <input type="email" id="email" name="email" required />
    </div>

    <button type="submit">Update Email</button>
}

Why this works:

  • Explicit configuration required: Global filter (ValidateAntiForgeryTokenAttribute) validates all POST actions; @Html.AntiForgeryToken() generates hidden __RequestVerificationToken field
  • MachineKey cryptography: Uses MachineKey for token encryption/signing - must be strong, unique per application, and identical across web farm servers
  • Defense-in-depth cookies: Web.config settings (requireSSL, httpOnlyCookies, sameSite="Strict") prevent token theft and cross-site transmission
  • HMAC-SHA256 binding: Combines cryptographic nonce with user session/identity, preventing tampering and cross-session reuse
  • Manual approach: Lacks modern conveniences (auto-injection, SPA support) but underlying cryptography remains sound; global filter recommended over per-action attributes

Custom anti-forgery validation

// CustomAntiforgeryAttribute.cs - Custom implementation
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System.Security.Cryptography;

public class CustomAntiforgeryAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        var httpContext = context.HttpContext;
        var method = httpContext.Request.Method;

        // Only validate state-changing methods
        if (method == "POST" || method == "PUT" || 
            method == "DELETE" || method == "PATCH")
        {
            // Get token from session
            var sessionToken = httpContext.Session.GetString("CSRF_TOKEN");

            // Get token from header or form
            var requestToken = httpContext.Request.Headers["X-CSRF-TOKEN"].FirstOrDefault()
                ?? httpContext.Request.Form["__RequestVerificationToken"].FirstOrDefault();

            if (string.IsNullOrEmpty(sessionToken) || 
                string.IsNullOrEmpty(requestToken) ||
                !CryptographicOperations.FixedTimeEquals(
                    System.Text.Encoding.UTF8.GetBytes(sessionToken),
                    System.Text.Encoding.UTF8.GetBytes(requestToken)))
            {
                context.Result = new StatusCodeResult(403);
                return;
            }
        }

        base.OnActionExecuting(context);
    }
}

// Usage
[CustomAntiforgery]
public class SecureController : Controller
{
    [HttpPost]
    public IActionResult SensitiveAction()
    {
        // CSRF validated by custom attribute
        return Ok();
    }
}

Why this works:

  • ActionFilter interception: OnActionExecuting validates tokens before controller actions, implementing Synchronizer Token Pattern with session storage
  • Constant-time comparison: CryptographicOperations.FixedTimeEquals prevents timing attacks where attackers measure response times to infer token bytes
  • Hybrid support: Validates both X-CSRF-TOKEN header (AJAX) and __RequestVerificationToken form field (traditional forms)
  • Proper HTTP semantics: Returns 403 Forbidden (not 401) - authentication succeeded but request lacks authorization
  • Production note: Educational implementation; use ASP.NET Core's IAntiforgery service for battle-tested handling of edge cases, token lifetime, and data protection API integration

Blazor Server with anti-forgery

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddAntiforgery();

var app = builder.Build();

app.UseAntiforgeryToken();  // Add anti-forgery middleware

app.MapBlazorHub();
app.MapFallbackToPage("/_Host");

app.Run();

// Pages/_Host.cshtml
@page "/"
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Blazor App</title>
    <base href="~/" />
</head>
<body>
    <component type="typeof(App)" render-mode="ServerPrerendered" />

    <!-- Anti-forgery token for Blazor forms -->
    <antiforgery-token />

    <script src="_framework/blazor.server.js"></script>
</body>
</html>

// Components/TransferForm.razor
@inject ITransferService TransferService
@inject NavigationManager Navigation

<EditForm Model="@transferRequest" OnValidSubmit="@HandleTransfer">
    <!-- Anti-forgery token automatically included -->
    <DataAnnotationsValidator />

    <div>
        <label>To Account:</label>
        <InputText @bind-Value="transferRequest.ToAccount" />
    </div>

    <div>
        <label>Amount:</label>
        <InputNumber @bind-Value="transferRequest.Amount" />
    </div>

    <button type="submit">Transfer</button>
</EditForm>

@code {
    private TransferRequest transferRequest = new();

    private async Task HandleTransfer()
    {
    // CSRF protection handled automatically by Blazor
        await TransferService.TransferAsync(transferRequest);
        Navigation.NavigateTo("/success");
    }
}

Why this works:

  • WebSocket-specific protection: SignalR uses WebSockets (not HTTP), so <antiforgery-token /> validates the initial HTTP handshake before upgrading to WebSocket
  • Connection-based security: Once WebSocket established, it's stateful and bound to originating browser tab - attackers cannot hijack connections or create new ones without valid token
  • Initial validation: UseAntiforgeryToken() validates tokens during HTTP request and circuit establishment; subsequent component interactions inherit validated connection
  • Efficient for interactive apps: Connection authentication (not per-request tokens) suits Blazor's frequent state changes
  • Defense-in-depth: SameSite cookies prevent initial connection from malicious sites; inherently more CSRF-resistant than traditional request-response apps

Verification

After implementing the recommended secure patterns, verify the fix through multiple approaches:

  • Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
  • Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
  • Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
  • Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
  • Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
  • Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
  • Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
  • Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced

Additional Resources