Blog
8 min readseguridad · csrf · formularios

CSRF explained with forms and cookies

An email-change request shows why a valid session does not prove that a user started the action.

CSRF made more sense to me once I stopped thinking about attacks and followed a normal request.

Someone signs into their account, changes their email address and clicks save. From the interface, it looks straightforward. There is a form, a session cookie and an endpoint that updates the data.

The issue is not that flow. It is that a browser can send part of the same information while that person is visiting another website.

CSRF means Cross-Site Request Forgery. It is an attempt to make an application accept an action using the session of someone who has already signed in. Nobody needs to steal the cookie or know the password. The browser may attach the cookie for them.

This matters to me because it challenges a convenient assumption about forms. A request with a valid session tells you who is authenticated. It does not tell you who chose to send it.

The request you expected to receive

On an account page, a form to change an email address could look like this

<form action="/account/email" method="post">
  <label for="email">New email</label>
  <input id="email" name="email" type="email" />
  <button type="submit">Save changes</button>
</form>

The server receives the new address and the session cookie. It uses that cookie to locate the person and apply the change.

So far, that seems enough. The mistake is assuming that the server will only see requests that went through that screen. An HTML form can send data to another domain. If someone is already signed in to your application, their browser can attach the cookie depending on how it is configured.

The server cannot see the button, the design or where the form was visually created. It only sees a request with a session it knows and data that changes an account.

That is where authentication stops being enough.

What still needs to be proved

When the server receives a request to change data, it has two separate jobs.

  • Identify who owns the session.
  • Check whether the request belongs to a flow the application allows.

The cookie handles the first. The second needs another signal.

That signal is often a CSRF token generated by the server. It is included when the form is rendered and returned with the request.

<form action="/account/email" method="post">
  <input type="hidden" name="csrf" value="server-generated-token" />
  <label for="email">New email</label>
  <input id="email" name="email" type="email" />
  <button type="submit">Save changes</button>
</form>

Before changing the email, the server checks the token and the session. Another site can try to send a form to your domain, but it should not be able to read the account page and learn a valid token.

The hidden field is not special on its own. An attacker can create hidden fields too. Protection comes from the server generating, storing or verifying the value and rejecting requests that do not include a valid one.

That is also why a CSRF token does not belong in a URL. URLs can end up in browser history, logs or a Referer header after someone follows a link.

SameSite tells the browser when it should send a cookie on requests coming from another site.

With Strict, the cookie is more restricted. That can improve protection, but it can also break flows that need to arrive from another domain.

With Lax, some navigation cases still work. It is a useful starting point for many sessions, but it does not make every data-changing action safe.

None allows the cookie in cross-site contexts and requires Secure. It can be needed for integrations or embedded applications. It also means authenticated actions need more care.

There is no setting that works the same way for every project. It depends on the sign-in flow, subdomains, integrations and the actions each account can perform. SameSite should not become a replacement for validating actions on the server.

When there is no visible form

In a modern interface, the email change may use fetch rather than a traditional form submission. The issue remains when authentication depends on cookies.

In that situation, many applications send the CSRF token in a custom header. An HTML form from another origin cannot freely add it. The server validates it and CORS decides which origins can call the API.

Responsibilities are easy to mix up here. CORS is not complete CSRF protection. A token does nothing if the server does not validate it. Opening CORS to every origin because “the API is public” can undo part of what you thought was protected.

Before writing that logic from scratch, I would look at the protection already provided by the framework. Understanding how to configure and test it is better than inventing a token nobody revisits six months later.

The frontend still has work to do

The server generates and validates the token, but the frontend is still part of the implementation.

It has to send the token with the form or API request. It has to keep it out of URLs. It has to handle a renewed session, the browser back button or an error when a token is no longer valid.

It is also worth separating what each control protects. HttpOnly stops JavaScript from reading a sensitive cookie. Secure keeps it off HTTP. SameSite limits certain cross-site sends. None of those settings proves by itself that a user started a request.

My article on common security mistakes in web forms looks at what comes into an application. CSRF adds another question. Who caused this action to reach the server?

XSS is related but different. If someone can run JavaScript inside your own origin, they can read the token your page uses. A CSRF token does not make up for XSS. My article on XSS explained for frontend developers covers that side.

What I am taking into future projects

Not all of my projects have user accounts today. A static portfolio does not need this kind of defence. When I build something that can change data, save progress or perform actions through a session, I want security to be part of the request design from the start.

I do not want to finish an interface and then find out that the server accepts a change just because a valid cookie arrived. The screen can be clear and the form can validate well. The important decision still sits behind it in the conditions the server requires before changing something.

That is what studying security alongside frontend work is giving me. An apparently small action has a technical path behind it, and the server needs different evidence for different problems.