CORS, Cookies, Unity and WebGL Builds

In a previous blog post I discussed how to get basic cross-origin requests working for your Unity WebGL project. This post is meant as a quick follow-up to cover another tricky problem that may come up when attempting to host your Unity WebGL game on Kongregate: cookie-based session authentication.

The Problem

Let's say you have a backend API that uses cookies for session management. Your game works great when testing on your own domain, but breaks horribly once you host the files on Kongregate due to the fact that your API requests are now cross-domain and subject to strict CORS rules.

This means that the browser will refuse to send cookies along with requests unless things are set up properly on both the client and the server. Keep in mind that this behavior is a good thing, as it means that it allows you to control what domains your session cookies will be sent to, which helps prevent this highly sensitive data from falling into the hands of malicious actors.

Configuring the Server

In order for the browser to allow sending of cookies to the destination server, it must first receive a response to a preflight OPTIONS HTTP request that contains the following header: Access-Control-Allow-Credentials: true. In addition, the Access-Control-Allow-Origin header must not be set to a wildcard, and instead must be set to the specific origin that is making the request. Example headers for an HTML5 game hosted on Kongregate with game ID 12345 (HTML5 games on Kongregate are hosted on their own subdomain that is based on the game ID) would be as follows:

Access-Control-Allow-Origin: https://game12345.konggames.com
Access-Control-Allow-Credentials: true

If you need to support multiple origins you can set Access-Control-Allow-Origin to the value of the Origin header sent by the client, but make sure to validate the value against a whitelist so that you aren't just enabling your session cookies to be sent to any origin domain. Many HTTP frameworks provide relatively easy ways to handle such whitelists.

An example of how to set this up for a Node Express server with the CORS middleware follows (again, for a fake game ID of 12345) and an origin whitelist is below:

var express = require('express')
var cors = require('cors')
var app = express()

var whitelist = ['https://game12345.konggames.com'];
var corsOptions = {
  credentials: true,
  origin: function (origin, callback) {
    if (whitelist.indexOf(origin) !== -1) {
      callback(null, true)
    } else {
      callback(new Error('Not allowed by CORS'))
    }
  }
};

app.use(cors(corsOptions));
app.options('*', cors(corsOptions)); // Enable options for preflight

app.get('/', (req, res) =>  res.send('Hello World!'))
app.listen(8080, () =>  console.log(`Example app listening on port 8080!`))

I can then do a quick cURL command to check the headers for an OPTIONS preflight request from an origin in the whitelist array:

curl -X OPTIONS -H"Origin: https://game12345.konggames.com" -v http://localhost:8080/
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> OPTIONS / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.58.0
> Accept: */*
> Origin: https://game12345.konggames.com
>
< HTTP/1.1 204 No Content
< X-Powered-By: Express
< Access-Control-Allow-Origin: https://game12345.konggames.com
< Vary: Origin, Access-Control-Request-Headers
< Access-Control-Allow-Credentials: true
< Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
< Content-Length: 0
< Date: Tue, 24 Sep 2019 22:04:08 GMT
< Connection: keep-alive
<
* Connection #0 to host localhost left intact

You can see that the various Access-Control headers are present and contain the proper values allowing CORS to work properly.

Note that if my Origin header is set to something else (like, say, game ID 54321), I get a 500 server error and the request is properly rejected:

curl -X OPTIONS -H"Origin: https://game54321.konggames.com" -v http://localhost:8080/
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> OPTIONS / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.58.0
> Accept: */*
> Origin: https://game54321.konggames.com
>
< HTTP/1.1 500 Internal Server Error

This is just a very basic example of how to set your server up to allow cross-origin requests. You may want to configure things in a more granular fashion (such as only allowing cookies on certain endpoints, for example), but that is all application-specific and out of the scope of this article.

Configuring the Client

Now that the server is ready to go, we need to instruct the client to include cookies when it makes a cross-domain request. You can opt in to this behavior for AJAX/XMLHttpRequest via the withCredentials property. If that property is set to true and the server has sent a compatible Access-Control-Allow-Credentials response header during preflight, then the Cookie request header will be included with the request.

If the preflight response did not include Access-Control-Allow-Credentials: true, or if your Access-Control-Allow-Access is set to a wildcard (*) then the cookies will not be sent and you are likely to see errors in your browser's Javascript console:

Access to XMLHttpRequest at 'https://api.mygamebackend.com' from origin 'https://game54321.konggames.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: 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'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

Unity's UnityWebRequest and the older WWW classes useXMLHttpRequest under the hood to fetch data from remote servers. Since there is no option to set the withCredentials flag to true, we have to perform a pretty dirty hack when initializing our application in order to turn that on for the appropriate requests.

In your WebGL template or generated index.html you can add the following script tag (before the application is initialized) to set withCredentials to true for requests sent to a specific domain (for this example we use the fictional domain https://api.mygamebackend.com; you will obviously need to substitute your own in its place). Note that since the example below turns on the flag for all endpoints on an entire domain, you may need to customize the URL check to only send credentials to certain endpoints (or multiple domains) etc.:

 <script>  
   XMLHttpRequest.prototype.originalOpen = XMLHttpRequest.prototype.open;  
   var newOpen = function(_, url) {  
     var original = this.originalOpen.apply(this, arguments);  
     if (url.indexOf('https://api.mygamebackend.com') === 0) {  
       this.withCredentials = true;  
     }  
  
     return original;  
   }  
   XMLHttpRequest.prototype.open = newOpen;  
 </script>

This snippet of code overrides the open method of XMLHttpRequest so that we can conditionally set withCredentials equal to true when desired. Once this is in place, cross-origin cookies should begin working between the Kongregate-hosted iframe domain and the game's backend servers!

Safari

Safari does not allow iframes to set cookies unless the site has been visited in a top-level window by default. In order to work around this, you can redirect the window to a page on the domain you wish to set the cookie for (if a test cookie is not present), and then have that page set a test cookie and redirect back to the referrer. This is documented pretty well in this gist and there is a Javascript implementation that can be found here.

Troubleshooting

CORS issues can be incredibly frustrating to track down and fix. When troubleshooting non-trivial CORS requests, there are several tools that really come in handy:

  • cURL - A simple curl -I <endpoint> or curl -X OPTIONS -v <endpoint> can reveal a ton of information about what is happening related to CORS. It can allow you to set different origins, check preflight responses, and more.
  • Browser network inspectors - Using the built-in browser tools can also help you track down which request and response headers are being sent and received for each endpoint. Typically this allows you to spot where things are going wrong if you look closely enough.
  • Browser Javascript consoles - Make sure to look for errors/warnings in the browser console when debugging. They often contain very helpful information when things are going south.
  • Charles Proxy - A general purpose HTTP proxy that allows you to view and modify traffic in real time.
  • test-cors.org - This is a very useful tool for sanity checking your CORS implementation.