DEV Community 👩‍💻👨‍💻

Steven Liao
Steven Liao

Posted on

Authenticating Remix cookie sessions with Django cookie sessions

Prerequisites

This post assumes you have knowledge of

  • Remix sessions
  • an external backend service that already manages user cookie sessions/authentication (like Django sessions)

Anecdote

I recently converted an app at work to Remix, and I wanted to share one of the issues I had ran into and solved. The app's backend was in Django, and its user authentication/sessions were also handled by Django. There was no problem with user authentication back when the app was an SPA since the browser was directly sending the fetch calls to the backend. It became a problem though when using Remix loader/action to fetch data from the backend; the fetch calls did not know which user was logged in when sending requests to my external backend service. After some experimentation, I've come up with a solution. I had to duplicate the cookie session handling in Remix:

  1. In the Remix loader/action fetch calls, take the response and parse the headers's Set-Cookie.
  2. Create a Remix cookie session storage that handle encoding/decoding the session values.
  3. Create new headers and return the headers with the response in the loader/action.

More details down below.

Create a session storage in Remix to store the session ID

I first had to create a cookie session storage in Remix. The name of the cookie had to match the name of the cookie header from my Django backend. In this case, it's sessionid.

// app/session.server.ts

export const sessionIdSessionStorage = createCookieSessionStorage({
  cookie: {
    name: 'sessionid',
    path: '/',
    secrets: [SESSION_SECRET],
    secure: process.env.NODE_ENV === 'production',
    // More cookie options could be added here, but we will be using the cookie options from the fetch responses later.
  },
})
Enter fullscreen mode Exit fullscreen mode

Fetching the login API in Remix action

My app had a login route with an action that takes the username and password to log the user in. The important part here is in the action of the route.

// app/routes/login.tsx

import type { ActionFunction, CookieSerializeOptions } from 'remix'
import setCookie from 'set-cookie-parser'
import { sessionIdSessionStorage } from '~/session.server'

const LOGIN_QUERY = /* GraphQL */ `
  mutation Login($username: string, $password: string) {
    ...
  }
`

export const action: ActionFunction = ({ request }) => {
  const formData = await request.formData()
  const username = formData.get('username')
  const password = formData.get('password')
  const response = await fetch('https://my-backend.com/graphql', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      query: LOGIN_QUERY,
      variables: {
        username,
        password,
      }
    })
  })

  if (response.status !== 200) {
    return ...
  }

  // Ensure that the response has a `Set-Cookie` header. It's required for every subsequent request that requires authentication.
  const setCookieHeader = response.headers.get('Set-Cookie')

  if (!setCookieHeader) {
    // No set cookie header meant something went wrong. Return an error.
    return ...
  }

  // Parse the cookies and find the `sessionid` cookie.
  const parsedResponseCookies = setCookie.parse(setCookie.splitCookiesString(setCookieHeader))
  const sessionIdCookie = parsedResponseCookies.find((cookie) => cookie.name === 'sessionid')

  if (!sessionIdCookie) {
    // No `sessionid` cookie in the fetch response means something went wrong.
    return ...
  }

  // Create the headers that will need to be returned to the browser. These headers are needed for every subsequent request that require authentication.
  const headers = new Headers()

  // Store the response's `sessionid` cookie into the headers.
  const { name, value, ...sessionIdCookieSerializeOptions } = sessionIdCookie
  const sessionIdSession = await sessionIdSessionStorage.getSession(request.headers.get('Cookie'))
  sessionIdSession.set(name, value)
  headers.append(
    'Set-Cookie',
    await sessionIdSessionStorage.commitSession(
      sessionIdSession,
      // Use the response's `sessionid` cookie serialization options.
      sessionIdCookieSerializeOptions as CookieSerializeOptions,
    ),
  )

  // Make sure to return a response with the headers you just created.
  return redirect("/profile", { headers })
}

const Login = () => {
  const actionData = useActionData()
  return ...
}

export default Login
Enter fullscreen mode Exit fullscreen mode

Now after a user logs in, in the browser's Application -> Cookies tab, you should see the sessionid key value there. With every fetch request, the browser sends this sessionid in its header cookies.

Image description

Subsequent fetch requests that requires user to be authenticated

I had to redirect the user to another route after the user logs in. That route requires some user-specific data. How can we tell Remix that the user is logged in? We need to tell Remix to read the requests' Set-Cookie and forward the decoded values in the fetch requests:

// app/routes/profile.tsx

import type { LoaderFunction } from 'remix'
import { json, redirect, useLoaderData } from 'remix'
import { sessionIdSessionStorage } from '~/session.server'

const PROFILE_QUERY = /* GraphQL */ `
  query Profile {
    ...
  }
`

export const loader: LoaderFunction = ({ request }) => {
  // The browser should automatically send the `sessionid` header cookie when this route is loaded.
  const sessionIdSession = await sessionIdSessionStorage.getSession(request.headers.get('Cookie'))

  // Ensure that the `sessionid` is present.
  if (!sessionIdSession.has('sessionid')) {
    // If it is not present, then the user has not been logged in.
    throw redirect('/login')
  }

  const response = await fetch('https://my-backend.com/graphql', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // IMPORTANT: send the `sessionid` cookie with the fetch request! This will tell your backend that the fetch is user-authenticated.
      Cookie: Object.entries(sessionIdSession.data)
        .map(([key, value]) => `${key}=${value}`)
        .join('; '),
    },
    body: JSON.stringify({
      query: PROFILE_QUERY,
    })
  })

  // Add some checks to make sure the fetch went through without errors.
  if (!response.ok || response.status !== 200) {
    // Return some errors or throw a redirect.
    return ...
  }

  const result = await response.json()

  // OPTIONAL: There's a chance that your backend returned new `sessionid` cookie headers in the profile query fetch as well. You could perform the same steps from the login action to store the new `sessionid` cookie in the browser.
  // const headers = new Headers()
  // const setCookieHeader = response.headers.get('Set-Cookie')
  // ...
  // return json(result, { headers })

  return json(result)
}

const Profile = () => {
  const loaderData = useLoaderData()
  return ...
}

export default Profile
Enter fullscreen mode Exit fullscreen mode

I've created abstractions for the cookie parsing so that I don't have to repeat those steps in every loader/action, and if you're doing this too, I'd recommend you do the same.

Top comments (4)

Collapse
jgb profile image
Jean Gérard Bousiquot

Nice! I'm wondering: if your app was an SPA working with the session data from the API, but since it's no more, is it possible to return a JWT token from the GraphQL API instead and store that in the remix session and send it back on subsequent requests?

Collapse
2ezpz2plzme profile image
Steven Liao Author

Yes. In your loader, you need to return the Set-Cookie header with the JWT token response though to the browser. Then every fetch from your browser will include that JWT token in the cookie header.

Collapse
jgb profile image
Jean Gérard Bousiquot

Hey Thank you! I actually did exactly that.

Collapse
akoumjian profile image
Alec Koumjian

Hey Steven,
Thanks for the excellent writeup. I'm curious, what benefit is there to using Remix's session framework here as opposed to manipulating the cookies directly?

🌚 Browsing with dark mode makes you a better developer by a factor of exactly 40.

It's a scientific fact.