PHP Session to React without CORS issue (or: how to solve PHP Session is changing on every request). Bonus: CSRF!
If you are developing a React app with a PHP backend (or a PHP framework such as Laravel) maybe you have encounter this issue where:
- The php session id is changed on every request
- When you save data into the session on the backend, it is not available for you on the next request.
The issues above are actually related. Since the session id changes on every new request, the data is saved into a new session. Subsequent request don’t use the previously generated session, so they can’t get the data that was saved into it.
This is happening especially during development, because using create_react_app is deploying the local app on http://localhost:3000 while your backend might be on another domain.
For me, I was using docker as a development server, and I connected to it using internal IP (172.17.0.2, for example). As a result, the communication between the frontend and the server backend becomes cross domains, which complicates the things a little bit.
The Solution
If you just came here for the solution, then add following changes to your code. Below I will explain some stuff that I have observed about how it works, so you can check if you need to debug.
Add this line to App.js:
axios.defaults.withCredentials = true;
I am using axios, if you are using something else, try to find what you need to add. Basically what this means is that axios will try to add the session cookie in the request.
In the backend, add the following code:
ini_set("session.cookie_domain", '.dev.local');
session_set_cookie_params(3600, '/', '.dev.local');
if(!isset($_SESSION)) {
session_start();
}// csrf code add here (see below...)$http_origin = $_SERVER['HTTP_ORIGIN'];
if ($http_origin == "http://dev.local:3000" || $http_origin == "http://localhost:3000"){
header("Access-Control-Allow-Origin: $http_origin");
}header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Headers: X-Requested-With, Origin, Content-Type, X-CSRF-Token, Accept');// code starts here
$_SESSION['test'] = 'whatever';
session_write_close();
Ideally session stuff should be the first thing that the php backend code does when the page loads. Make sure that no output is send before session_write_close()
is called, otherwise you will get an error that headers where are already sent
.
Notice that I am using the domain dev.local.
You need to add it to your hosts file.
The last thing is that you need to use the domain in order to access the app. So instead of using http://localhost:3000
, use http://dev.local:3000
.
It should work…
Below I will add some remarks of what I have observed of how it is working (I think). If things are not working, you can try to compare, maybe it can help you debug.
How it works
When you open devtools, and you looks at the requests, what we want to see is that requests are going out with a cookie named PHPHSESSID
that has the same value as the Set-Cookie
directive that was included in the response header.
If you don’t have this PHPSESSID
, or it has a different value on every request, then something is wrong.
The value of PHPSESSID
should be the same on all POST and GET requests (and responses).
PHP is generating a session id and sends it in the first response to the frontend. It expects to see the same session id in the request, otherwise it will start a new session
This is how the response headers of the first communication looks like:
See the PHPSESSID in #1. Also notice the Access-Control-Allow-*
directives (#2) in the response, which are the same as we set them in the backend (more on that below).
Subsequent requests from the client, all have the (same) PHPSESSID cookie in their request headers:
I have noticed that in preflight requests, (request that are of type Request Method: OPTIONS
) , the value of PHPSESSID
is different. As far as I have seen, this is not a problem.
If you are wondering what are these preflight messages, then this is where the browser is asking the server how to send the data. This is where the headers that defined above in the server side should take affect.
The frontend will add the PHPSESSID
cookie to the response only if:
- The
Access-Control-Allow-*
methods from the server allow it. - It has to be on the same domain as the backend.
- For some reason, it didn’t work with http://localhost . Another reason why I needed to use the http://dev.local domain.
For #2, the port is not considered as a different domain. So the cookie was added since frontend is running on http://dev.local:3000 while backend on http://dev.local .
If you omit or change some of the Access-Control-Allow-*
directives, you will encounter the dreadful CORS errors, that I am sure you have seen in the past.
Here are some examples:
The value of the ‘Access-Control-Allow-Credentials’ header in the response is ‘’ which must be ‘true’ when the request’s credentials mode is ‘include’.
Now that you understand how the code works, it is quite self explanatory. Since the client (React), is using the withCredetials
directive, the backend should have the Access-Control-Allow-Credential
set the true.
The value of the ‘Access-Control-Allow-Origin’ header in the response must not be the wildcard ‘*’ when the request’s credentials mode is ‘include’
Again, very simple. Since we are using Access-Control-Allow-Credential
, we must need to provide one domain (and one domain only) in the Access-Control-Allow-Origin
directive.
You get the picture, I hope..
What about CSRF?
When using sessions, you need to protect again CSRF. CORS by itself doesn’t protect against these types of attacks, so we will need to add additional code.
In the backend, add this:
$token_mismatch = isset($_SESSION['token']) && $_SESSION['token']!=$_SERVER['HTTP_X_CSRF_TOKEN'];
$token_request = isset($_GET['method']) && $_GET['method'] != "getCSRFToken");if ( $token_mismatch && !$token_request) {
header("HTTP/1.1 401 Unauthorized");
exit;
}
if (!isset($_SESSION['token'])){
$token = md5(uniqid(mt_rand(), true));
$_SESSION['token'] = $token;
}
header("token: $token");if ($_GET['method'] == "getCSRFToken"){
echo $_SESSION['token'];
exit;
}
This code is in charge of:
- preventing access if token is present but mismatched
- If session doesn’t have a csrf token, generate a random token, store it in the session, and add it to the response.
- Add an endpoint for the frontend to inquire the csrf token.
In your frontend, add a file csrf.js
:
import axios from "axios";export default function getCSRFToken() {
return new Promise((resolve, reject) => {
if (window.csrf_token_status === "available") return resolve();
if (window.csrf_token_status === "requested") {
setInterval(function(){
if (window.csrf_token_status === "available")
return resolve();
}, 500);
}else{
const server_url = window.ENV.site_url;
window.csrf_token_status = "requested";
axios
.get(server_url+'?method=getCSRFToken')
.then((response)=>{
axios.defaults.headers.post['X-CSRF-Token'] = response.data;
window.csrf_token_status = "available";
return resolve();
})
}
});
};
Then add these lines to App.js:
import getCSRFToken from './csrf';
window.csrf_token_status = "unavailable";const App = () => {
useEffect(() => {
getCSRFToken();
}, [])
}
If users starts a session, but then refreshes the tab, it will make a new request to get the csrf token.
Frontend then adds adds the token to every request using axios.defaults
There is also a bunch of code that is used to make sure there is only one csrf get token request, and waiting until a token is available.
The End
And I hope that this post will help you with your session issue. If it did help you, or you have questions, or you have a improvement suggestion, do comment or reach out, I am always happy to know/assist.