As apps grow, almost all will need user authentication. Perhaps they need to store and display user-specific data. Or maybe there are features that only privileged users should have access to.
Let’s take a look at how authentication is implemented in Remix.
Overview Of The Approach
When the user logs in, we’ll take the userId
and store it in a cookie session. Cookie values are sent with every request to the server, and the server will use this to see if the user has a valid user ID (and is, therefore, logged in).
We’ll check for userId
in the loaders and actions when the user visits a restricted page. If it is not valid, we’ll redirect them to the login page.
The login page will only be available to users who haven’t already logged in. When a logged-in user visits the login page, we’ll redirect them to the home page.
Logging out will destroy the current session. If a user tries to access a restricted page after logging out, there won’t be a userId
in the cookie session, and they’ll be redirected to the login page.
The Example App
We’re going to create a web app for L.O.V.E.M.U.F.F.I.N.
They’re not the most ethical client, but times are tough!
Our app will have three pages (or routes):
- A home page. This will be accessible to everyone, but we’ll show different content if the user has logged in.
- A login page, which will only be accessible if the user has not already logged in.
- An evil deeds page, where authenticated evil-doers can submit their schemes to a database.
Creating The Cookie Session
Let’s start by creating a file to handle our session logic, session.server.ts
.
We’re going to store the user ID in a cookie session. Remix makes managing cookie sessions easy by providing an abstraction, createCookieSessionStorage
.
import { createCookieSessionStorage } from "@remix-run/node";
const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
path: "/",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET],
secure: process.env.NODE_ENV === "production",
},
});
After we’ve created our session, we’ll add another function that will take a Request
object and return the current session.
async function getSession(request: Request) {
const cookie = request.headers.get("Cookie");
return sessionStorage.getSession(cookie);
}
Finally, we’ll create a logout
function, which will take a Request
object, destroy the current session, and redirect the user back to an unprotected page (in this case, the home page).
import { redirect } from "@remix-run/node";
export async function logout(request: Request) {
const session = await getSession(request);
return redirect("/", {
headers: {
"Set-Cookie": await sessionStorage.destroySession(session),
},
});
}
The Login Page
Now that we have our session created, we’re ready to make the login page. Let’s create a basic login form.
import { Form } from "@remix-run/react";
export default function LoginPage() {
return (
<Form method="post">
<label htmlFor="email">Email address</label>
<input
id="email"
required
name="email"
type="email"
autoComplete="email"
/>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
/>
<button type="submit">Log in</button>
</Form>
);
}
When the user submits the form, the client will send a POST request to the server. This is handled by the route’s action.
In our action, we’ll want to perform the following steps:
- Validate the form’s data.
- Verify the login.
- Create the user session if the email and password are valid.
import { createUserSession } from "~/session.server";
import { verifyLogin } from "~/models/user.server";
export async function action({ request }: ActionArgs) {
const formData = await request.formData();
const email = formData.get("email");
const password = formData.get("password");
// Perform form validation
// For example, check the email is a valid email
// Return the errors if there are any
const user = await verifyLogin(email, password);
// If no user is returned, return the error
return createUserSession({
request,
userId: user.id,
});
}
Once we have verified that the username and password are valid and we have some way to uniquely identify the user (i.e. the userId
), we can create the user session. Let’s jump back to session.server.ts
to implement it.
import { redirect } from "@remix-run/node";
const USER_SESSION_KEY = "userId";
export async function createUserSession({
request,
userId,
}: {
request: Request;
userId: string;
}) {
const session = await getSession(request);
session.set(USER_SESSION_KEY, userId);
return redirect("/", {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session, {
maxAge: 60 * 60 * 24 * 7 // 7 days,
}),
},
});
}
First, we define the USER_SESSION_KEY
. This helps us avoid typos when we try and get the userId
from the session and means we only have to update it in one place.
Then we define createUserSession
. We get the current cookie session from the request and update it with the user’s userId
.
Finally, we redirect the user to the home page ("/"
) with the updated session committed in the header. This behaviour is good enough for now but later we’ll look at redirecting the user to the page they were trying to access before they were redirected to the login page.
Protecting the Login Page
Now that we are allowing users to log in and update the cookie session, we can update the login page so that it’s only accessible to users who haven’t logged in. If a user tries to get to the login page after they’ve logged in, we’ll redirect them to the home page.
Let’s start by updating session.server.ts
to create a function that will get the userId
from the current session if a userId
exists.
import type { User } from "~/models/user.server";
export async function getUserId(
request: Request
): Promise<User["id"] | undefined> {
const session = await getSession(request);
const userId = session.get(USER_SESSION_KEY);
return userId;
}
Now we can add a loader
to routes/login.tsx
that checks if there is a userId
in the request. If there is, we’ll redirect the user to the home page.
import type { LoaderArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { getUserId } from "~/session.server";
export async function loader({ request }: LoaderArgs {
const userId = await getUserId(request);
if (userId) return redirect("/");
return json({});
}
Personalised Routes
We’ve been redirecting our user back to the home page so let’s go ahead and make it now.
Our home page will show different things depending on whether the user is logged in.
- If they aren’t logged in, we’ll show them a login button.
- If they are logged in, we’ll show a welcome message and a link to the page where they can submit their cunning plans.
Let’s start by adding a function to session.server.ts
to get the user’s details, if any, from the current session.
import { getUserById } from "~/models/user.server";
export async function getUser(request: Request) {
const userId = await getUserId(request);
if (userId === undefined) return null;
const user = await getUserById(userId);
if (user) return user;
throw await logout(request);
}
Then we’ll return the user’s data from our root route’s loader.
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import { getUser } from "./session.server";
export async function loader({ request }: LoaderArgs) {
return json({
user: await getUser(request),
});
}
export default function App() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
Once our root loader is returning the user data, we can create a custom hook to use that data in any child route.
import { useMatches } from "@remix-run/react";
import type { User } from "~/models/user.server";
export function useMatchesData(
id: string
): Record<string, unknown> | undefined {
const matchingRoutes = useMatches();
const route = useMemo(
() => matchingRoutes.find((route) => route.id === id),
[matchingRoutes, id]
);
return route?.data;
}
function isUser(user: any): user is User {
return user && typeof user === "object" && typeof user.email === "string";
}
export function useOptionalUser(): User | undefined {
const data = useMatchesData("root");
if (!data || !isUser(data.user)) {
return undefined;
}
return data.user;
}
useMatchesData("root")
will return the loader data from the root route. We then check if there is a user in the data and return undefined
if there isn’t. If there is, we return the user.
We can now use this hook to check on the client if there is a user logged in and access that user’s data. Let’s try it out with our index route.
import { Link } from "@remix-run/react";
import { useOptionalUser } from "~/utils";
export default function Index() {
const user = useOptionalUser();
if (user) {
return (
<>
<h1>Welcome, Dr. {user.lastName}</h1>
<Link to="/evil-deeds">Submit evil deeds</Link>
</>
);
}
return (
<>
<h1>Sorry, nothing to see here 👀</h1>
<Link to="/login">Log In</Link>
</>
);
}
Protected Routes
Now we have a login page and a customised home page, it’s time to create the top secret “Evil Deeds” page. This will be a protected route, meaning only users who have successfully logged in can access the route.
Our approach to protected routes will be to create a new function, requireUserId
. This will be similar to getUser
, but instead of returning null
if there is no userId
in the current cookie session, we’ll redirect the user to the login page.
import { redirect } from "@remix-run/node";
export async function requireUserId(
request: Request,
) {
const userId = await getUserId(request);
if (!userId) {
throw redirect('/login');
}
return userId;
}
Notice that if userId
is undefined
, we throw redirect('/login')
. This is one of the coolest features of Remix’s loaders and actions. If something happens that disrupts our happy path, we can throw
a redirect, an error, or anything at all. Remix will then take over and handle what happens next, allowing us to focus solely on the happy path. So nice!
Let’s add our new function to the start of our protected route’s loader so if the user isn’t logged in, they’re redirected immediately.
import { json } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { requireUserId } from "~/session.server";
export async function loader({ request}: LoaderArgs) {
await requireUserId(request);
return json({});
}
export default function NoteDetailsPage() {
return (
<div>
<p>
Send us your evil deed ideas and you might appear
in the next episode of Dragon's Den
</p>
<Form method="post">
<label htmlFor="deed">
Evil Deed Idea
</label>
<input name="deed" />
<button type="submit">
Submit
</button>
</Form>
</div>
);
}
Notice that even though we’re not using the returned userId
, requireUserId
can still be called to prevent unauthorised users from accessing this route.
Once we’ve added this protection to our loader, it can be easy to think that the work is done. However, it’s important to remember that our actions function as endpoints. These are entry points to our backend server and need to be protected too.
Let’s add the action to our route, making sure to only allow it to be called if the user is logged in.
import type { ActionArgs } from "@remix-run/node";
import { createEvilDeed } from "~/models/evil-deed.server";
export async function action({ request }: ActionArgs) {
const userId = await requireUserId(request);
const formData = await request.formData();
const title = formData.get("deeds");
// Evil deed validation
await createEvilDeed({ deed, userId });
return redirect('/');
}
Once again, we start by calling requireUserId
. This time we do want to use the userId
so that when we send our form data to the database, it can be associated with the correct user.
Once the database has been updated, we redirect the user back to the home page. Alternatively, we could redirect them to a success page or a page to view all their previous submissions.
Logout
Once our user has submitted their evil deeds, we need to provide them with a way to log out so they can go back into hiding.
We’ll do this by creating a new route, routes/logout.tsx
. This route won’t be used to display anything, it’ll be used to define an action that other routes will be able to call.
import type { ActionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { logout } from "~/session.server";
export async function action({ request }: ActionArgs) {
return logout(request);
}
export async function loader() {
return redirect("/");
}
We define a loader so that if anyone tries to navigate to /logout
, we’ll redirect them back to the home page.
The action calls the logout
function we defined at the start. This function gets the current cookie session from the request, destroys it, and redirects the user back to the home page.
Now we can update /evil-deeds
to include a logout button.
<Form action="/logout" method="post">
<button type="submit">
Logout
</button>
</Form>
In this form, we specify the action as "/logout"
. This will call the action in the /logout
route we just created instead of the default behaviour of calling the action in the current route (or a parent action if none exists).
And there we have it, a fully authenticated Remix app 🎉
You can find the full, up-to-date version of this by creating a Remix app using the Indie Stack template. Alternatively, feel free to reach out to me on Twitter if you have any questions.
Appendix 1: Remember Me
Let’s take another look at our login page and add a way for a user to decide whether they should stay logged in after they close the current session.
Under our submit button in the login form, let’s add a “Remember me” radio button.
import { Form } from "@remix-run/react";
export default function LoginPage() {
return (
<Form method="post">
<label htmlFor="email">Email address</label>
<input
id="email"
required
name="email"
type="email"
autoComplete="email"
/>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
/>
<button type="submit">Log in</button>
<input
id="remember"
name="remember"
type="checkbox"
/>
<label htmlFor="remember">Remember me</label>
</Form>
);
}
Now the formData
in the action will also have the user’s decision of whether they want to be remembered. We can pass this value to createUserSession
and set the maxAge
of the cookie based on whether the user wants the session to persist.
import { createUserSession } from "~/session.server";
import { verifyLogin } from "~/models/user.server";
export async function action({ request }: ActionArgs) {
const formData = await request.formData();
const remember = formData.get("remember");
// ...
return createUserSession({
request,
userId: user.id,
remember: remember === "on" ? true : false,
});
}
export async function createUserSession({
request,
userId,
remember,
}: {
request: Request;
userId: string;
remember: boolean;
}) {
const session = await getSession(request);
session.set(USER_SESSION_KEY, userId);
return redirect("/", {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session, {
maxAge: remember
? 60 * 60 * 24 * 7 // 7 days
: undefined,
}),
},
});
}
Appendix 2: Redirect Back To Attempted Route
Finally, let’s make our redirects smarter.
If an unauthenticated user tries to access one of our protected routes, we redirect them to the login page. Then, once they log in, they’re redirected to the home page. Let’s update the code so that they’re redirected back to the protected route they were originally trying to access.
We might be tempted to store the redirectTo
URL in some kind of global UI state, like React context. However, a simpler and more suitable place is the URL itself. We can lift the state up and store the redirectTo
value as a search param.
There’s just one problem with putting the redirect location in the search params. They could easily be manually updated to whatever URL that person wants. This would leave us vulnerable to open redirect attacks.
To prevent this, let’s create a utility function, safeRedirect
. It’ll take the redirect destination and check that it’s safe. If it isn’t, it’ll return a default redirect instead.
const DEFAULT_REDIRECT = "/";
export function safeRedirect(
to: FormDataEntryValue | string | null | undefined,
defaultRedirect: string = DEFAULT_REDIRECT
) {
if (!to || typeof to !== "string") {
return defaultRedirect;
}
if (!to.startsWith("/") || to.startsWith("//")) {
return defaultRedirect;
}
return to;
}
Let’s update requiresUserId
to take an extra argument, redirectTo
. This will be an optional argument that will default to the current URL’s pathname. Then, when we redirect back to /login
, we can include the redirectTo
value.
export async function requireUserId(
request: Request,
redirectTo: string = new URL(request.url).pathname
) {
const userId = await getUserId(request);
if (!userId) {
const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
throw redirect(`/login?${searchParams}`);
}
return userId;
}
Now, for example, when the user tries to access /evil-deeds
before they’re authenticated, they’ll be redirected to /login?redirectTo=%2Fevil-deeds
.
In the login route, we need a way to let our action know what the redirectTo
search param is so it can redirect back to that URL once the user is logged in.
The easiest way to pass additional data from the client to the server is by adding hidden input elements to the form. These inputs won’t be visible to the user, but the values will be accessible to the server by using formData
.
import { Form, useSearchParams } from "@remix-run/react";
export default function LoginPage() {
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get("redirectTo") || "/";
return (
<Form method="post">
<label htmlFor="email">Email address</label>
<input
id="email"
required
name="email"
type="email"
autoComplete="email"
/>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
/>
<input type="hidden" name="redirectTo" value={redirectTo} />
<button type="submit">Log in</button>
</Form>
);
}
Use can use the Remix hook, useSearchParams
to access the current search params. We then pass that value to the hidden input, if there is a redirectTo
search param. Otherwise, we default back to "/"
.
In the action, we’ll take this input value, pass it through our safeRedirect
function, and pass it to createUserSession
.
import type { ActionArgs } from "@remix-run/node";
import { safeRedirect } from "~/utils";
export async function action({ request }: ActionArgs) {
const formData = await request.formData();
const redirectTo = safeRedirect(formData.get("redirectTo"));
// ...
import { createUserSession } from "~/session.server";
return createUserSession({
request,
userId: user.id,
redirectTo,
});
}
export async function createUserSession({
request,
userId,
redirectTo,
}: {
request: Request;
userId: string;
redirectTo: string;
}) {
const session = await getSession(request);
session.set(USER_SESSION_KEY, userId);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session, {
maxAge: 60 * 60 * 24 * 7, // 7 days
}),
},
});
}
And we’re done! Now when a user tries to access a protected page, we’ll redirect them back after they’ve logged in.