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:
- In the Remix loader/action fetch calls, take the response and parse the headers's
Set-Cookie
. - Create a Remix cookie session storage that handle encoding/decoding the session values.
- 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.
},
})
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
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.
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
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 (9)
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?
Yes. In your
loader
, you need to return theSet-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.Hey Thank you! I actually did exactly that.
Hi! Great write-up! Wanted to see if you've experimented with either or both:
remix-auth
Currently trying to work through both in my Remix-Django application but running into issues with both
Hi we are planning to do a similar setup with Remix, .net core api with remix front-end, in production how did you route the api calls to go to the backend? Thanks.
Very nice.
I managed to get this working on my .NET 7 API that also uses cookie sessions.
If anyone finds this, and wants to use this technique with remix-auth, you can see my discussion and resolution here: github.com/sergiodxa/remix-auth/di...
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?
How would one manipulate the cookies directly using Remix's built in functionalities?