Figuring out SPAs

managing an SPA is like juggling plates while riding a single-wheel bike
Image by Annie Ruygt

People love their precious SPAs! Host your frontend (and heck, even your backend) globally on Fly.io, and reap the rewards of low ping times.

SPAs always are a bit fragile. Nevermind the game of “where’s the business logic?” (a rant I won’t bother writing), splitting the frontend from the backend makes hosting SPAs harder.

The complications are mostly related to security concerns, such as dealing with CORS and cookie configuration. These often fail with confusing browser errors (on the frontend, where errors go to die - never to be read by a human).

I might be biased, but there’s a lot that can go wrong!

I don’t usually run SPAs myself, so when someone asked about doing it on Fly, I wanted to figure it out to ensure I understood all the issues. If I’m going to complain about SPAs, I better understand them!

Here’s my writeup about what it takes to separate your frontend from your backend. I’m writing in context of Laravel (and Sanctum), but this applies to most frameworks.

The Goal

We want our frontend (The JS making up our SPA) hosted on some url like my-app.fly.dev. The backend will be an API hosted on something like my-app-api.fly.dev.

Off the bat we have some hurdles:

  1. We’re using 2 domains, and Javascript is going to need permission to make HTTP requests to the API domain (via your best frenemy CORS)
  2. We need to handle authentication - in this case, cookie based auth (yes, cookies!) across 2 subdomains of the same root domain
  3. We also need to handle CSRF (cross-site request forgery) tokens to protect against malicious attempts to send data to our backend

So, we need our frontend to be able to authenticate and stay authenticated with our backend. It also needs to securely send data (mainly POST requests) to the API.

Authentication and authorization is often done with JWTs, but Laravel Sanctum provides a way to handle most of this using good-old-fashion cookies.

The Factors

There are the factors making SPA setups a bit painful!

Here’s a quick overview of them:

CORS & AJAX

Browsers don’t just let you send HTTP requests anywhere. By default, they only allow you to make requests to the same domain you see in the URL bar. To make requests to other domains (“Cross Origin”), the other domain needs to allow it. It does this via CORS headers. We need our backend to allow the frontend to send HTTP requests to it by setting the correct CORS headers.

Cookies & Domains

Similarly, cookies can only be set for specific domains. Browsers will ignore/omit cookies set for a different domain than the one a site is currently on. However, our backend and frontend domains may be different. In our case, since we want to make use of cookies, we need a common root domain, which will allow subdomains to share cookies. We’ll use cookies for authentication with the backend - the regular old use case for cookies - but across subdomains. You can’t use cookies across 2 totally different domains - browsers won’t let you.

CSRF

Related to cookies is the need for CSRF tokens with Laravel. Laravel uses these to protect against cross-site request forgery (hey, that’s “CSRF”). We’ll send our CSRF tokens along as another (encrypted) cookie to Laravel.

🙅 Fly.dev

Lastly, fly.dev is on the public suffix list. This has a side effect!

We can’t accomplish what we want using a *.fly.dev subdomain. Browsers won’t let you set cookies for all subdomains of public suffix domain. Instead, we need to use a custom domain in this setup! Besides, would you really want your cookies set to work on any site using the *.fly.dev domain? There’s…a lot of them.

Earlier I gave domains my-app.fly.dev and my-app-api.fly.dev as examples. Well, we can’t use those. Let’s pretend instead we set up a custom domain on Fly.io - we’ll use subdomains www.my-app.com and api.my-app.com.

From here we see how to take care of all of this stuff with the help of Laravel Sanctum.

Sanctum

The basics of installing and using Sanctum are the following few things:

composer require laravel/sanctum
php artisan vendor:publish \
    --provider="Laravel\Sanctum\SanctumServiceProvider"

# Assuming a DB is setup
php artisan migrate

Then comes the interesting stuff.

Middleware

For SPA authentication, add the following to your app/Http/Kernel.php for the API middleware:

'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],

This ensures requests are “stateful” for “first party” frontends (ones we have registered). Let’s go ahead and register those.

To do that, we need to tell Sanctum what domains we’ll use with “stateful” (cookie-based) auth via the SANCTUM_STATEFUL_DOMAINS env var.

Check file config/sanctum.php to find how that works:

return [
    // Other options omitted
    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
        '%s%s',
        'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
        Sanctum::currentApplicationUrlWithPort()
    ))),
    // ... and so on
];

The default configuration tries to set the stateful domains for you, but it’s defaulting to values best for local development.

To try this out with 2 different subdomains, I added the following to my environment (via .env locally and fly.toml for deploying to Fly.io):

SANCTUM_STATEFUL_DOMAINS="www.my-app.com,api.my-app.com"

If your SANCTUM_STATEFUL_DOMAINS are using non-standard ports (anything other than 80 or 443), be sure to include the ports in the hostnames, e.g. api.my-app.com:9000.

Configure CORS

We need to ensure the backend (API) is replying with the correct CORS headers to allow the frontend to send it HTTP requests.

From the Sanctum docs on SPA authentication:

In order to authenticate, your SPA and API must share the same top-level domain. However, they may be placed on different subdomains.

So, we’re going to launch two different Fly apps, which will use 2 different subdomains from the top-level domain my-app.com. (www.my-app.com and api.my-app.com).

Sanctum can help with the CORS headers for us! We just need to set some configuration.

Head to config/cors.php and set supports_credentials to true, and ensure any other routes you use are allowed in the paths array:

return [
    // Allow ourselves to login by adding a /login route
    'paths' => ['api/*', 'sanctum/csrf-cookie', 'login'],

    // Other options omitted

    // Ensure cors supports auth/csrf headers
    'supports_credentials' => true,
];

I added the login route so my frontend could send a POST request to /login on the backend to authenticate (and get returned a valid cookie).

Setting supports_credentials to true sets the correct CORS headers.

Configure Cookies

Cookies will, by default, only work for the current domain being used to access the site. However, we’ll be using 2 domains. Since these subdomains use the same root domain my-app.com, we can make the cookies work for both. (If they were totally different domains, we couldn’t do that).

So, for our multiple subdomains (www.my-app.com and api.my-app.com), we’ll set the SESSION_DOMAIN env var to .my-app.com (with the preceding period).

Check file config/session.php to find where that env var is set:

return [
    // Other options omitted
    'domain' => env('SESSION_DOMAIN'),
];

Configure Environment Variables

So far we have 2 environment variables set:

SESSION_DOMAIN=".my-app.com"
SANCTUM_STATEFUL_DOMAINS="www.my-app.com,api.my-app.com"

One quick note, however, is that Laravel needs the APP_URL to be set correctly as well. This should include the port used if using a non-standard (80,443) port.

Setting this correctly helps ensure that URLs generated by the code (including cookie domains, if you forget to set SESSION_DOMAIN manually) use the correct domain and protocol (http vs https).

So, all together, the important environment variables are:

# or maybe api.my-app.com:9000
# if using a non-standard port
APP_URL="api.my-app.com"

SESSION_DOMAIN=".my-app.com"
SANCTUM_STATEFUL_DOMAINS="www.my-app.com,api.my-app.com"

Fly.io ❤️ Laravel

Fly your servers close to your users—and keep your SPAs speedy. Deploy globally on Fly in minutes!

Deploy your Laravel app!

The Code

We need our backend (API) to generate a CSRF token for our frontend to send along anytime it makes HTTP requests to the backend (for authentication or for general API calls).

Since it comes with Laravel out of the box, I’ll assume you use Axios to make HTTP requests from your frontend. It has an option to help here - to have it send along cookies to the backend when making requests. The cookies passed along will both keep you authenticated, but also contain the CSRF token the backend provides us.

To handle this, the first thing to do is send users to a login page. Your frontend should send a request to the backend to get a CSRF token, and then allow a login to happen.

In your application, Axios should be set like so:

axios.defaults.withCredentials = true;

If you are not using Axios to make HTTP requests from your frontend, you should perform the equivalent configuration on your own HTTP client. It’s an underlying XMLHttpRequest feature rather than one specific to Axios.

Once that’s set, if your login form is something like this:

<form id="login" class="py-12">
    <h2 class="pb-4">Login</h2>
    <div class="mb-4">
        <input type="email" name="email" placeholder="email" class="border rounded p-2">
    </div>
    <div class="mb-4">
        <input type="password" name="password" placeholder="password" class="border rounded p-2">
    </div>
    <div class="mb-4">
        <button type="submit" class="border rounded p-2">Login</button>
    </div>
</form>

… then your JavaScript for that can look something like this (only “fancier” because presumably you’ll be using a framework and not just Vanilla Javascript™, like me):

window.addEventListener('load', (event) => {
    // Get CSRF token on page load
    window.axios.get('https://api.my-app.com/sanctum/csrf-cookie').then(response => {

        // We're now ready to login
        let form = document.querySelector('#login')

        form.addEventListener('submit', (event) => {
            event.preventDefault()

            window.axios.post('http://api.my-app.com/login', new FormData(form))
                .then(request => {
                    getUser()
                }).catch(error => {
                    console.log(error)
                    alert('could not auth')
                })
        });
    });
})

// Hit the API to get a user
function getUser() {
    window.axios.get('http://api.my-app.com/api/user')
        .then(response => {
            console.log(response.data)
            alert('we got our user ' + response.data.email)
        }).catch(error => {
            console.log(error)
            alert('could not get user')
        })
}

That’s it!

Sanctum and Axios do a lot of heavy lifting for us. Otherwise we’d have to do things like set up CORS ourself (yuck).

Easy, right? Just make sure everything is configured exactly correctly to get around (or, well, to work alongside with) browser security.

If you do something a little wrong, you’ll break your app. No sweat!

Pro Tip: If you’re going to do an SPA, and split your frontend from your backend, then I suggest using Breeze (with the API scaffolding) to set up authentication. It does a bunch of the manual work we just did for you.