Overview
Cross-Site Request Forgery (CSRF) is an attack that tricks a victim’s browser into submitting an unintended request to an application where they are already authenticated. This can lead to unauthorized actions like changing a password, making a purchase, or deleting an account, all without the user’s knowledge. The attack works because the browser automatically sends authentication cookies with the request.Business Impact
CSRF can lead to unauthorized financial transactions, data modification, or full account takeover (if used to change a user’s email or password). It erodes user trust and can cause significant financial and reputational damage.Reference Details
CWE ID: CWE-352
OWASP Top 10 (2021): A01:2021 - Broken Access Control
Severity: High
Framework-Specific Analysis and Remediation
Modern web frameworks (Rails, Django, Laravel, Spring Security, ASP.NET) have built-in CSRF protection enabled by default for session-based authentication. The vulnerability is not that the framework is weak, but that a developer disables this protection, often for convenience (e.g., for an API) or by mistake. The fix is to re-enable and correctly use the framework’s anti-CSRF token mechanism.- Python
- Java
- .NET(C#)
- PHP
- Node.js
- Ruby
Framework Context
Django’sCsrfViewMiddleware is enabled by default. It requires a {% csrf_token %} in all POST forms. The vulnerability is using the @csrf_exempt decorator on a view.Vulnerable Scenario 1: Exempting a Function-Based View
A view that changes a user’s email is explicitly exempted from CSRF checks for convenience.Copy
# users/views.py
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.decorators import login_required
@login_required
@csrf_exempt # DANGEROUS
def update_email(request):
if request.method == 'POST':
# This action can be triggered from a malicious site
request.user.email = request.POST.get('email')
request.user.save()
return render(request, 'profile.html')
Vulnerable Scenario 2: Exempting a Class-Based View
AFormView for deleting an account is exempted using @method_decorator.Copy
# users/views.py
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import FormView
@method_decorator(csrf_exempt, name='dispatch') # DANGEROUS
class DeleteAccountView(FormView):
# ... form_class and success_url ...
def form_valid(self, form):
# This deletion can be triggered by a malicious POST
self.request.user.delete()
return super().form_valid(form)
Mitigation and Best Practices
Remove the@csrf_exempt and @method_decorator(csrf_exempt) decorators. Ensure your POST form in the template includes the {% csrf_token %} tag. For AJAX, pass the token in a custom X-CSRFToken header.Secure Code Example
Copy
# users/views.py (Secure Version)
@login_required
# @csrf_exempt decorator is REMOVED
def update_email(request):
if request.method == 'POST':
request.user.email = request.POST.get('email')
request.user.save()
return render(request, 'profile.html')
# templates/profile.html (Secure Version)
/*
<form method="post">
{% csrf_token %} <label>Email:</label>
<input type="email" name="email">
<button type="submit">Update</button>
</form>
*/
Testing Strategy
Write an integration test that performs aPOST request to the endpoint without the CSRF token. The test should assert that the response is a 403 Forbidden.Copy
# users/tests.py
def test_update_email_fails_without_csrf_token(self):
# self.client is logged in
response = self.client.post(reverse('update-email'), {
'email': '[email protected]'
})
# A secure endpoint will return 403 Forbidden
self.assertEqual(response.status_code, 403)
Framework Context
Spring Security enables CSRF protection by default. It requires a unique token for all state-changing methods (POST, PUT, DELETE). The vulnerability is disabling it.Vulnerable Scenario 1: Disabling CSRF Globally
In theWebSecurityConfigurerAdapter, a developer disables CSRF protection entirely.Copy
// config/SecurityConfig.java
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf().disable(); // DANGEROUS: CSRF protection is off globally
}
Vulnerable Scenario 2: Disabling CSRF for Specific Routes
A developer disables CSRF for all routes under/api, thinking it’s only for a stateless API, but a stateful (session-based) endpoint is accidentally included.Copy
// config/SecurityConfig.java
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// DANGEROUS: If /api/profile/update uses session cookies,
// it is now vulnerable to CSRF.
.csrf().ignoringAntMatchers("/api/**")
.and()
.authorizeRequests()
.antMatchers("/api/profile/**").authenticated() // Session-based
.antMatchers("/api/public/**").permitAll()
.anyRequest().authenticated();
}
Mitigation and Best Practices
Remove the.csrf().disable() call. If you must disable CSRF for a stateless API, ensure those routes (.antMatchers("/api/stateless/**")) are truly stateless (e.g., use JWT bearer tokens) and that your session-based routes remain protected.Secure Code Example
Copy
// config/SecurityConfig.java (Secure Version)
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin();
// SECURE: .csrf().disable() is removed. Protection is ON.
}
// templates/profile.html (Secure Version with Thymeleaf)
/*
<form th:action="@{/update-profile}" method="post">
<label>Email:</label>
<input type="email" name="email">
<button type="submit">Update</button>
</form>
*/
Testing Strategy
Write a MockMVC test using@WithMockUser. Perform a post() request without the csrf() request processor. Assert the response is a 403 Forbidden.Copy
@Test
@WithMockUser
void updateProfile_withoutCsrfToken_shouldBeForbidden() throws Exception {
mockMvc.perform(post("/update-profile")
.param("email", "[email protected]"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser
void updateProfile_withCsrfToken_shouldSucceed() throws Exception {
mockMvc.perform(post("/update-profile")
.param("email", "[email protected]")
.with(csrf())) // SECURE: Adds a valid CSRF token
.andExpect(status().isOk());
}
Framework Context
ASP.NET Core automatically generates and validates anti-forgery tokens for Razor Pages and MVC forms created with tag helpers. The vulnerability is a developer forgetting to add the[ValidateAntiForgeryToken] attribute or explicitly ignoring it.Vulnerable Scenario 1: Missing Validation Attribute
An endpoint that modifies data is missing the validation attribute.Copy
// Controllers/ProfileController.cs
[Authorize]
public class ProfileController : Controller
{
[HttpPost]
// DANGEROUS: This action does not validate the anti-forgery token.
public async Task<IActionResult> Update(string email)
{
// ... update logic
return RedirectToAction("Index");
}
}
Vulnerable Scenario 2: Globally Ignoring Validation
A developer globally disables anti-forgery token validation inStartup.cs.Copy
// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews(options =>
{
// DANGEROUS: This globally disables CSRF validation for all POST actions.
options.Filters.Add<IgnoreAntiforgeryTokenAttribute>();
});
}
Mitigation and Best Practices
Add the[ValidateAntiForgeryToken] attribute to all [HttpPost], [HttpPut], and [HttpDelete] actions. For a safer “deny by default” approach, add [AutoValidateAntiforgeryToken] as a global filter in Startup.cs, which automatically protects all state-changing methods.Secure Code Example
Copy
// Startup.cs (Secure - Global)
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews(options =>
{
// SECURE: Automatically validates tokens on all non-GET methods.
options.Filters.Add<AutoValidateAntiforgeryTokenAttribute>();
});
}
// Controllers/ProfileController.cs (Secure - Method-specific)
[Authorize]
public class ProfileController : Controller
{
[HttpPost]
[ValidateAntiForgeryToken] // SECURE: This attribute enables validation.
public async Task<IActionResult> Update(string email)
{
// ... update logic
return RedirectToAction("Index");
}
}
// Views/Profile/Index.cshtml (Secure Version)
/*
<form asp-controller="Profile" asp-action="Update" method="post">
<input type-="email" name="email" />
<button type="submit">Update</button>
</form>
*/
Testing Strategy
Write an integration test that authenticates a client. The test then creates anHttpRequestMessage for a POST but does not include the __RequestVerificationToken cookie or form data. Assert the response is a 400 Bad Request.Copy
[Fact]
public async Task Update_Without_AntiForgeryToken_ShouldFail()
{
// _client is authenticated
var postRequest = new HttpRequestMessage(HttpMethod.Post, "/Profile/Update");
var formData = new Dictionary<string, string> { { "email", "[email protected]" } };
postRequest.Content = new FormUrlEncodedContent(formData);
// We are NOT adding the token, so this should fail
var response = await _client.SendAsync(postRequest);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
Framework Context
Laravel’sVerifyCsrfToken middleware is enabled by default for the web route group. The vulnerability is adding a state-changing route to the $except array or incorrectly using cookie-based auth on an “API” route.Vulnerable Scenario 1: Exempting a Route
A developer exempts the profile update route from CSRF protection in the middleware.Copy
// app/Http/Middleware/VerifyCsrfToken.php
class VerifyCsrfToken extends Middleware
{
protected $except = [
// DANGEROUS: This route can now be attacked via CSRF
'/profile/update',
];
}
Vulnerable Scenario 2: Using Session Auth on API Route
Theroutes/api.php file does not have CSRF protection. A developer defines a route here but authenticates using the web session cookie instead of a stateless token.Copy
// routes/api.php
// DANGEROUS: If a user is logged into the main app (with a session),
// and this route uses that session cookie for auth, it is vulnerable
// to CSRF because the `api` middleware group is stateless by default.
Route::post('/user/delete', [UserController::class, 'delete'])
->middleware('auth:web'); // Using web auth in API
Mitigation and Best Practices
Remove the route from the$except array. Ensure your Blade template form includes the @csrf directive. Routes in api.php must be stateless (e.g., use Sanctum API tokens or JWTs), not web session cookies.Secure Code Example
Copy
// app/Http/Middleware/VerifyCsrfToken.php (Secure Version)
class VerifyCsrfToken extends Middleware
{
// SECURE: The route is no longer in the exception list.
protected $except = [
//
];
}
// resources/views/profile.blade.php (Secure Version)
/*
<form method="POST" action="/profile/update">
@csrf <input type="email" name="email">
<button type="submit">Update</button>
</form>
*/
Testing Strategy
Write a feature test.actingAs a user, then make a post request to the endpoint without the _token data. Assert the response is a 419 (Session Expired / Token Mismatch).Copy
// tests/Feature/ProfileUpdateTest.php
public function test_profile_update_fails_without_csrf_token()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/profile/update', [
'email' => '[email protected]'
]);
// 419 is Laravel's status code for CSRF failure
$response->assertStatus(419);
}
Framework Context
Express has no built-in CSRF protection. The vulnerability is simply not using a library to add it, or misconfiguring it. The standard library iscsurf.Vulnerable Scenario 1: No CSRF Middleware
A standard Express app withcookie-session but no CSRF middleware.Copy
// app.js
const express = require('express');
const app = express();
app.use(require('cookie-session')({ keys: ['secret'] }));
app.use(express.urlencoded({ extended: false }));
app.post('/profile/update', (req, res) => {
// DANGEROUS: No CSRF token is checked. Any site can
// POST to this endpoint and the user's cookie will be sent.
req.session.user.email = req.body.email;
res.redirect('/profile');
});
Vulnerable Scenario 2: Middleware Applied Incorrectly
Thecsurf middleware is added, but after the route it’s supposed to protect.Copy
// app.js
const csrf = require('csurf');
app.use(express.urlencoded({ extended: false }));
// DANGEROUS: The vulnerable route is defined before
// the CSRF middleware is applied.
app.post('/profile/update', (req, res) => {
req.session.user.email = req.body.email;
res.redirect('/profile');
});
// The middleware is applied too late.
app.use(csrf());
Mitigation and Best Practices
Add thecsurf middleware before you define any routes that modify state. This middleware creates a req.csrfToken() function. You must pass this token to your template and include it in your form.Secure Code Example
Copy
// app.js (Secure Version)
const express = require('express');
const csrf = require('csurf'); // Import csurf
const app = express();
app.use(require('cookie-session')({ keys: ['secret'] }));
app.use(express.urlencoded({ extended: false }));
app.use(csrf()); // SECURE: Use the csurf middleware before routes
app.get('/profile', (req, res) => {
// SECURE: Pass the token to the render function
res.render('profile', { csrfToken: req.csrfToken() });
});
app.post('/profile/update', (req, res) => {
// SECURE: The csurf middleware validates the token automatically.
req.session.user.email = req.body.email;
res.redirect('/profile');
});
// views/profile.ejs (Secure Version)
/*
<form method="POST" action="/profile/update">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="email" name="email">
<button type="submit">Update</button>
</form>
*/
Testing Strategy
Use Jest/Supertest. Get the login cookie, then attempt toPOST to the update endpoint without first GETting the page to scrape the token. The request should fail with a 403 Forbidden.Copy
// tests/profile.test.js
it('should fail to POST without a CSRF token', async () => {
// 'userAgent' is a logged-in supertest agent
const response = await userAgent
.post('/profile/update')
.send({ email: '[email protected]' });
expect(response.statusCode).toBe(403);
});
Framework Context
Rails hasprotect_from_forgery with: :exception in ApplicationController by default. This is secure. The vulnerability is a developer adding skip_before_action :verify_authenticity_token.Vulnerable Scenario 1: Skipping the Filter for an Action
A controller skips the authenticity check for a single action.Copy
# app/controllers/profile_controller.rb
class ProfileController < ApplicationController
before_action :authenticate_user!
# DANGEROUS: Disables CSRF protection for the update action
skip_before_action :verify_authenticity_token, only: [:update]
def update
if request.post?
current_user.update(email: params[:email])
end
redirect_to profile_path
end
end
Vulnerable Scenario 2: Skipping the Filter for a Controller
A developer, annoyed by CSRF errors during API development, skips the filter for the entire controller.Copy
# app/controllers/api_controller.rb
class ApiController < ApplicationController
# DANGEROUS: If any method in this controller uses session auth,
# it is now vulnerable to CSRF.
skip_before_action :verify_authenticity_token
def change_settings
# ... logic using `current_user` ...
end
end
Mitigation and Best Practices
Remove theskip_before_action :verify_authenticity_token line. Ensure your forms in .html.erb files are generated with form_with or form_for, which automatically include the token. For APIs, use protect_from_forgery with: :null_session and use token-based auth.Secure Code Example
Copy
# app/controllers/profile_controller.rb (Secure Version)
class ProfileController < ApplicationController
before_action :authenticate_user!
# SECURE: The 'skip_before_action' is removed.
def update
if request.post?
current_user.update(email: params[:email])
end
redirect_to profile_path
end
end
# app/views/profile/show.html.erb (Secure Version)
/*
<%= form_with url: profile_update_path, method: :post do |form| %>
<%= form.email_field :email %>
<%= form.submit "Update" %>
<% end %>
*/
Testing Strategy
Write an RSpec request spec. Log in as a user. Then,post to the update path without the authenticity_token. This is hard to do in request specs as Rails often adds it. A better test is a controller spec where you explicitly disable token verification to see it fail.The easiest way is to test that protection is on.Copy
# spec/controllers/profile_controller_spec.rb
it 'is protected from forgery' do
# This checks that the 'protect_from_forgery' is active
expect(ProfileController).to be_protect_from_forgery
end

