This is the 2nd part in a two-part series on CSRF. If you haven’t read Part 1 of this series, I recommend checking that out first as this part discusses the ways in which we can prevent CSRF attacks!
Press enter or click to view image in full size
In this article, we will be looking into some major mitigation strategies that we can use to prevent CSRF attacks. We will also be doing a live demo for each of the mitigations to get a practical understanding.
The Crux: Why does CSRF Occur
CSRF arises from the combination of two core issues:
- Browsers automatically attach credentials to every request (same-site and cross-site).
- Servers can’t distinguish whether a request was initiated by a trusted origin (unless additional checks is done).
Do We Need to Fix Both Core Issues to Prevent CSRF?
No, fixing either one can be sufficient to stop CSRF, but each comes with trade-offs. In most applications, properly addressing either one of the issues is sufficient for CSRF protection, but combining both is best practice, especially for sensitive apps like banks.
Lets deep dive into the major type of Mitigations below.
1. SameSite Cookie Attribute
SameSite
is a cookie attribute (similar to HTTPOnly
, Secure
etc.) which aims to mitigate CSRF attacks and is a browser defence. This attribute helps the browser decide whether to send cookies along with cross-site requests. The possible values for this attribute are Lax
, Strict
, or None
.
✅Mitigates Core Issue 1 (Browsers automatically attach credentials)
❌Does not help with Core Issue 2, because it works at the browser level, not the server level.
1.1 How does this cookie work with each of its values ?
SameSite=Strict
- Cookies which have the
Strict
value set are sent only for same site requests. Cross site requests will be blocked 🙂 - This is very secure, but it can break user experience.
Eg: When the user clicks on a link from their email (an external origin), the browser won’t send the session cookie which would force the user to login again which does not make it very user friendly.
2. SameSite=Lax
(Default in most modern browsers)
- Cookies are sent for same-site requests and cross site requests which use safe HTTP methods(GET, HEAD, OPTIONS, and TRACE) AND have top-level navigations.
- A top-level navigation is when the URL in the browser’s address bar changes. This can happen by clicking a link, submitting a form OR being redirected. This behavior helps block CSRF attacks that rely on POSTing from malicious sites(state changing attacks), while still allowing normal links (like from emails) to work properly as they are GET requests 🥳.
3. SameSite=None
- Cookies are sent on all cross-site requests, but must be marked as
Secure
(i.e., sent only over a HTTPS connection).
Clearly not what we want for preventing CSRF 😶.
1.2 SameSite Mitigation Live Demo
Goal: We’ll set the session cookie with the SameSite=Lax
attribute to ensure that the attacker's phishing page cannot send a cross-site POST request to the transfer API.
In the index.js
file of the app, the session middleware is updated to the below content.
app.use(
session({
secret: 'csrf-demo-secret',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax'
/*
Setting SameSite attribute to LAX so the session cookie is
not sent in a POST cross site request (Transfer API request)
*/
},
})
);
Lets clone our demo bank application https://github.com/goku007xx/csrf-demo-playground and run it with the steps given on the README.
We have our bank’s login page at http://samesite-bank.com
. The victim will be the user alice
who has 1000$ in her bank account. Lets simulate the victim authenticating to the bank with the username and password as alice
.
Now let’s simulate the phishing page that the attacker has sent to Alice at the link http://CatsThatHack.com/index-samesite.html
. Alice clicks the Transfer Money
button. This time, the attack fails — instead of triggering the transfer, Alice is simply redirected to the bank’s login page, as her session cookie was not sent with the cross-site request .
We can also see the transfer funds POST request in the network tab. We can see that the session cookie is not sent in the attacker’s request which means our mitigation has worked 🥳.
Press enter or click to view image in full size
Press enter or click to view image in full size
2. CSRF Token — Synchronizer Token Pattern
CSRF Token — Synchronizer Token Pattern is a server-side mitigation which works by generating a unique, unpredictable token on the server once per session and embedding it in each form or page. When the user submits a form, the server verifies that the submitted token matches the one stored in the user’s session. Since an attacker’s site can’t access or guess this token, forged malicious requests will be rejected.
❌Does not help with Core Issue 1 (Browsers automatically attach credentials)
✅Mitigates Core Issue 2, because it gives the server a way to find out if the request is coming from a legitimate user or an attacker.
2.1 Why can’t the attacker forge the CSRF token ?
- Even if an attacker tricks the victim into visiting a malicious page, they can’t use JavaScript (e.g.,
fetch
orXMLHttpRequest
) to read the contents of the target page (like the bank’s form containing the CSRF token).
The Same-Origin Policy (SOP) ensures that JavaScript can’t access responses from other domains so the token cannot be accessed.
- An attacker might try to fetch the login page using tools like
curl
,axios
, orrequests
to scrape the CSRF token but that token would belong to their own session, not the victim’s.
Each CSRF token is unique to a specific session (and user), the attacker’s token is useless in the victim’s context.
Here’s the mitigation workflow we’ll be implementing for our demo bank application. Take a quick look before we dive into the demo.
Press enter or click to view image in full size
2.2 Synchronizer CSRF Token Mitigation Live Demo
Goal: We’ll generate a CSRF token and associate it with Alice’s session. This token will also be embedded within the transfer form. When a transfer is initiated, the server will compare the token from the form with the one stored in the session to validate the request and block any potential CSRF attacks.
In the index.js
of the bank app, we add a generateCSRFToken
function and we also map the the token to the user’s session during the login.
// CSRF token generation function
function generateCSRFToken() {
return crypto.randomBytes(32).toString('hex');
}// POST /login
app.post('/login', (req, res) => {
const { username, password } = req.body;
/*
IRRELEVANT CODE...
*/
req.session.csrfToken = generateCSRFToken();
return res.redirect('/transfer');
});
Next, we need to embed the CSRF token into the transfer form when the page loads. Then, when a transfer is submitted (via a POST request), we’ll validate the request by comparing the token from the form with the one stored in the user’s session. If the validation fails, return a csrf validation failed
HTML page.
// GET /transfer
app.get('/transfer', requireLogin, (req, res) => {
const csrfToken = req.session.csrfToken
const transferPath = path.join(__dirname, 'views', 'transfer.html'); // Inject CSRF token into the transfer form when it is loaded.
fs.readFile(transferPath, 'utf8', (err, data) => {
const modifiedHtml = data.replace(
'<form method="POST" action="/transfer">',
`<form method="POST" action="/transfer">
<input type="hidden" name="csrfToken" value="${csrfToken}">`
);
res.send(modifiedHtml);
});
});
app.post('/transfer', requireLogin, (req, res) => {
const { amount, to, coupon, csrfToken } = req.body;
// Validate CSRF token when a transfer is done
if (!csrfToken || csrfToken !== req.session.csrfToken) {
return res.status(403).sendFile(path.join(__dirname, 'views', 'csrf-failed.html'));
}
/*
IRRELEVANT CODE...
*/
});
We have our bank’s login page at http://synchronizer-bank.com/login
. Lets login with the user alice
again to simulate a victim user logging in. Once done, lets go the phishing page that the attacker has sent to Alice at the link http://catsthathack.com/index-synchronizer.html
.
Clicking the transfer money
button does not trigger the money transfer and instead takes us to the CSRF validation page which blocks the attack 🥳. This happens because the attacker’s phishing page doesn’t have access to Alice’s CSRF token, so it isn’t included in the request. As a result, the transfer API call fails, which you can also confirm in the network tab, where the csrfToken
field appears empty. Mitigation Successful :)
Press enter or click to view image in full size
Press enter or click to view image in full size
3. CSRF Token — Double Submit Pattern
Alright! We’ll be using the CSRF token again but in a slightly different way for the 3rd major mitigation🙂.
CSRF Token — Double Submit Pattern is a server-side mitigation that works by generating a CSRF token on the server and sending it to the client (usually via a cookie). The same token is also embedded in each form or request as a hidden field or header. When the user submits a request, the server compares the token from the cookie with the one submitted in the form. It does seem a bit similar to the Synchronizer Pattern right ? 🤔
Unlike the Synchronizer Token Pattern, this method does not require server-side storage of the token, since validation is done by comparing two values sent by the client(the form’s token and the cookie’s token).
❌Does not help with Core Issue 1 (Browsers automatically attach credentials)
✅Mitigates Core Issue 2, because it gives the server a way to find out if the request is coming from a legitimate user or an attacker.
Here’s the mitigation workflow we’ll be implementing for our demo bank application. Take a quick look before we dive into the demo.
Press enter or click to view image in full size
3.1 Double Submit Token Mitigation Live Demo
Goal: We’ll generate a CSRF token on the server and send it to Alice’s browser via a cookie. This same token will also be embedded within the transfer form as a hidden field. When a transfer is initiated, the server will compare the token from the form with the one in the cookie to validate the request and prevent any potential CSRF attacks. This is done without storing the token on the server.
Alright so during our login, we generate our secure CSRF token and then set its value to the csrfToken
cookie. The cookie must be httpOnly: false
as our transfer page has to access the cookie’s contents from the JS. Once that’s in place, the Transfer API can compare the CSRF token from the request (hidden form field) with the one in the cookie. If they are not equal, then return a csrf validation failed
HTML page. The index.js
file’s snippets are given below.
// CSRF token generation function
function generateCSRFToken() {
return crypto.randomBytes(32).toString('hex');
}// POST /login
app.post('/login', (req, res) => {
/* ... */
req.session.username = username;
const csrfToken = generateCSRFToken();
res.cookie('csrfToken', csrfToken , {
httpOnly: false, // Must be readable by client-side JavaScript
secure: false // Set to false due to HTTP
});
return res.redirect('/transfer');
});
// POST /transfer
app.post('/transfer', requireLogin, (req, res) => {
const cookie = req.cookies.csrfToken;
const tokenFromForm = req.body.csrfToken;
// If the cookie or token from form is missing, send the CSRF failed page
if (!cookie || !tokenFromForm) {
return res.status(403).sendFile(path.join(__dirname, 'views', 'csrf-failed.html'));
}
// If the cookie and token from form do not match, send the CSRF failed page
if (cookie !== tokenFromForm) {
return res.status(403).sendFile(path.join(__dirname, 'views', 'csrf-failed.html'));
}
let amt = parseInt(amount, 10);
/* ... */
});
Also, we will have to make some modifications in transfer.html so that it can get the csrfToken
from the cookie to the form’s hidden field. The getCookie
function takes care of that 🙂.
<form method="POST" action="/transfer">
<label for="to">Recipient Username</label> <!-- ....... -->
<!-- This csrfToken field will be populated by the function below -->
<input type="hidden" name="csrfToken" id="csrfToken" value="">
<button type="submit">Send</button>
</form>
<script>
// Get CSRF token from cookie and set it above.
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop();
}
}
document.getElementById('csrfToken').value = getCookie('csrfToken') || '';
</script>
We have our bank’s login page at http://double-submit-bank.com/login
. Lets login and then go the phishing page that the attacker has sent to Alice at the link http://catsthathack.com/index-doublesubmit.html
.
Clicking the transfer money
button does not trigger the money transfer and instead takes us to the CSRF validation page which blocks the attack 🥳.
It does seem similar to the last mitigation, but what’s different ? 🤔
We see that both cookies(the session cookie and csrfToken cookie) are sent in the request because of the browser’s default behavior of sending cookies. But, the request fails because the hidden field in the form is empty because catsthathack.com
could not use JS to get double-submit-bank.com
‘s cookie (Same Origin Policy).
Mitigation Successful :)
Press enter or click to view image in full size
Conclusion
And that’s a wrap! We covered three solid CSRF defenses each with its own strengths and weaknesses.
Of course, these aren’t the only ways to prevent CSRF. You can also use techniques like custom headers with CORS or rely on frameworks that have CSRF protection built-in. But hopefully this post helped you on the fundamentals of how to protect your application from CSRF attacks 🙂.
👉 Want to try these yourself? Check out this Github repository to explore and play around with these CSRF mitigation scenarios hands-on.
Also check out Part 1 of this article if you haven't already!