← Back to blog

Remix & Firebase Authentication

Elliot Hesp

CEO

21st January, 2022

Need a custom tool? Invertase can help

Tired of investing in off-the-shelf software that doesn't quite fit your business? Invertase can build solutions tailored to your exact needs. Tell us what you're looking for here and we'll be in touch.

Firebase Authentication enables you to really easily build an authentication flow within your application, however in most scenarios you’ll likely be performing this logic using the client SDK where the users authentication state is stored on the browser.

Remix is a full stack web framework which allows you to build out your application, however is primarily focused on using a server to serve your pages. When it comes to authentication, you’ll want to be preventing the user from accessing the page until they’re authenticated via the server – rather letting the client check whether they can access it.

So, how do we get Remix & Firebase Authentication to play nicely together?

Setting up a project

If you haven’t already done so, create a new Remix project:

npx create-remix@latest

Next, lets go ahead and install the Firebase client SDK & the Firebase Admin SDK:

npm i --save firebase firebase-admin

Setting up Firebase

We’ll be using both the Firebase client SDK and admin SDK for this, so first lets create a two new files which will act as our entry point to the SDKs.

First, create the file for the client sdk under app/firebase.client.ts. Grab your configuration from the Firebase Console:

// app/firebase.client.ts

import { initializeApp } from "firebase/app";
import { getAuth, inMemoryPersistence } from "firebase/auth";

const app = initializeApp({
  apiKey: "...,
  authDomain: "...,
  projectId: "...,
  storageBucket: "...,
  messagingSenderId: "...,
  appId: "...,
});

const auth = getAuth(app);

// Let Remix handle the persistence via session cookies.
setPersistence(auth, inMemoryPersistence);

export { auth };

You’ll notice here that we’re setting the authentication persistence to “in memory”. This basically tells Firebase Auth to only store the authentication state in memory – so if you refresh the page you’ll lose that state. We’ll be storing the authentication state on the server, so this is exactly what we want.

Next, create the file for the admin sdk under app/firebase.server.ts. For this, you’ll need to reference a service account file from the Firebase Console.

// app/firebase.server.ts

import { App, initializeApp, getApps, cert, getApp } from "firebase-admin/app";
import { Auth, getAuth } from "firebase-admin/auth";

let app: App;
let auth: Auth;

if (getApps().length === 0) {
  app = initializeApp({
    credential: cert(require("../path-to-your-service-account.json")),
  });
  auth = getAuth(app);
} else {
  app = getApp();
  auth = getAuth(app);
}

export { auth };

The admin SDK doesn’t allow initialization of the same app more than once – since Remix provides some hot-reloading on file changes this will trigger initialization more than once, so we first check if a Firebase App instance has been initialized and return it if it already has been.

Authenticating the user

Now we have our Firebase setup configured, let’s go ahead and authenticate a user!

Let’s assume we already have a user account on our Firebase project as an email/password user, create a new login page with a basic form:

// app/routes/login.tsx

export default function Login() {
  async function handleSubmit() {
    // ..
  }

  return (
    
      Login
      
        
        
        Login
      
    
  );
}

We’ve got a basic form here, however we don’t actually want to submit the form via a standard form submission. Instead, we want to intercept the “submit” button and attempt to log the user in. Lets go ahead and create the handleSubmit function:

// app/routes/login.tsx

import { SyntheticEvent } from "react";
import { signInWithEmailAndPassword } from "firebase/auth";
import { auth as clientAuth } from "~/firebase.client";

export default function Login() {
  async function handleSubmit(e: SyntheticEvent) {
    e.preventDefault();
    const target = e.target as typeof e.target & {
      email: { value: string };
      password: { value: string };
    };

    const email = target.email.value;
    const password = target.password.value;

    try {
      const credential = await signInWithEmailAndPassword(clientAuth, email, password);
      const idToken = await credential.user.getIdToken();
      // TODO: Handle idToken
    } catch (e: Error) {
      // Handle errors...
    }
  }

  return (
    
      Login
      
      // ...
}

Ok, so when our form is submitted, we grab the email & password values and attempt to log the user in (using signInWithEmailAndPassword). Upon a successful login, we’ll then get the user’s idToken.

However, since we enabled in-memory persistence the authentication state will be lost if we reload the page.

Creating a session cookie

With a users ID Token, we can use the Firebase Admin SDK to call the createSessionCookie function, which generates a JWT token. This token can be stored in a cookie – which we’ll later use for authentication.

To create this cookie, we first need to create an action:

// app/routes/login.tsx

import { ActionFunction } from "remix";
import { auth as serverAuth } from "~/firebase.server";

export const action: ActionFunction = async ({ request }) => {
  const form = await request.formData();
  const idToken = form.get("idToken")?.toString();

  // Verify the idToken is actually valid
  await serverAuth.verifyIdToken(token);

  const jwt = await serverAuth.createSessionCookie(idToken, {
    // 5 days - can be up to 2 weeks
    expiresIn: 60 * 60 * 24 * 5 * 1000,
  });

  // ...
};

Since we previously handled the form submission ourselves, we need to programmatically trigger the action with the form data (which should include the idToken). To do this, we can make use of the useFetcher hook:

// app/routes/login.tsx

import { useFetcher } from "remix";

export default function Login() {
  const fetcher = useFetcher();

  async function handleSubmit(e: SyntheticEvent) {
    e.preventDefault();
    const target = e.target as typeof e.target & {
      email: { value: string };
      password: { value: string };
    };

    const email = target.email.value;
    const password = target.password.value;

    try {
      const credential = await signInWithEmailAndPassword(
        clientAuth,
        email,
        password,
      );
      const idToken = await credential.user.getIdToken();

      // Trigger a POST request which the action will handle
      fetcher.submit({ idToken }, { method: "post", action: "/login" });
    } catch (e: Error) {
      // Handle errors...
    }
  }

  // ...
}

To summarize the flow:

  1. The user enters their email & password into the form.
  2. The user presses the “submit” button.
  3. The submission is intercepted via the handleSubmit function.
  4. The email & password is extracted the form event.
  5. The signInWithEmailAndPassword function is called to attempt to authenticate the user.
  6. On success, the getIdToken function is called to get the user’s idToken.
  7. The idToken is sent via a POST request to our action.
  8. The action validates the idToken is valid & creates a JWT (with a custom expiry date).

Storing the JWT in a cookie

Now we have a user JWT token, we need to store this as a cookie in a session… luckily Remix makes this super easy.

First, create a app/cookies.ts file:

// app/cookies.ts

import { createCookie } from "remix";

export const session = createCookie("session", {
  secrets: ["some secret"],
  // Ensure this is the same as the expiry date on the JWT!!
  expires: new Date(Date.now() + 60 * 60 * 24 * 5 * 1000),
  path: "/",
});

This cookie will be used to store the JWT token – one detail to be aware of if you need to ensure the expiry time you defined when creating the JWT token (via the expiresIn property) matches up with the cookie expire date. If these don’t match, you’ll end up with the JWT expiring before the cookie does, or the cookie being deleted before the JWT expires.

Back within the action, you can now redirect (or whatever you want to do) with a Set-Cookie header which will inform the browser to store the cookie in the browser’s session:

// app/routes/login.tsx

import { redirect } from "remix";
import { session } from "~/cookies";

export const action: ActionFunction = async ({ request }) => {
  const form = await request.formData();
  const idToken = form.get("idToken")?.toString();

  // Verify the idToken is actually valid
  await serverAuth.verifyIdToken(token);

  const jwt = await serverAuth.createSessionCookie(idToken, {
    // 5 days - can be up to 2 weeks
    expiresIn: 60 * 60 * 24 * 5 * 1000,
  });

  return redirect("/", {
    headers: {
      "Set-Cookie": await session.serialize(jwt),
    },
  });
};

On success, the action will redirect the user to the / route and inform the browser to save our cookie in the browser’s session! If you open the browser’s developer console, you’ll see the cookie is now stored with an expiry date. If you refresh the page it should still be there (if it isn’t, make sure you set an expiry date to sometime in the future!).

Authenticating the user

Now we’ve got the users JWT token stored in a cookie, we can use it to ensure they’re authenticated. For example, let’s say we want to display the users profile information on the /profile route:

// app/routes/profile.tsx

import { LoaderFunction, redirect } from "remix";
import { session } from "~/cookies";
import { auth as serverAuth } from "~/firebase.server";

export const loader: LoaderFunction = async ({ request }) => {
  // Get the cookie value (JWT)
  const jwt = await session.parse(request.headers.get("Cookie"));

  // No JWT found...
  if (!jwt) {
    return redirect("/login");
  }

  try {
    const token = serverAuth.verifySessionCookie(jwt);

    // Get the user's profile using the token from somewhere (Firestore, Remote Database etc)
    const profile = await getUserProfile(token.uid);

    // Return the profile information to the page!
    return {
      profile,
    };
  } catch (e: Error) {
    // Invalid JWT - log them out (see below)
    return redirect("/logout");
  }
};

In this logic, we first attempt to see if there is a valid session cookie store on the users browser (which is sent to the Request). If not, redirect them away. If there is, ensure the returned JWT is both valid and not expired. Once confirmed it’s valid, use the users id to perform some logic (e.g. getting a user profile).

This logic would best be extracted into a utility function so you can reuse it across other pages & even actions.

Logging out

To log a user out, it’s as simple as informing the browser it needs to delete the cookie. In Remix, this is really straightforward – create a app/routes/logout.ts file with a loader function:

// app/routes/logout.ts

import { LoaderFunction, redirect } from "remix";
import { session } from "~/cookies.ts";

export const loader: LoaderFunction = () => {
  return redirect("/", {
    headers: {
      "Set-Cookie": await session.serialize("", {
        expires: new Date(0),
      }),
    },
  });
};

Now to log a user out, simply link them to the /logout route – they will be redirect to the / and the session cookie will be removed (since the date is in the past, the browser will remove the cookie from its session).

Wrapping up

There we have it – simple authentication with minimal fuss.

The great thing about this approach is that you can implement types of provider login, for example if you wanted to use GitHub, Google, Facebook, Twitter, etc login, just pass the returned UserCredential to your login action!

// app/routes/login.tsx

import { signInWithPopup, GithubAuthProvider } from "firebase/auth";
import { auth as clientAuth } from "~/firebase.client";

export default function Login() {
  const fetcher = useFetcher();

  async function onProviderSignIn(credential: UserCredential) {
    const idToken = await credential.user.getIdToken();
    fetcher.submit({ token }, { method: "post", action: "/login" });
  }

  return (
     {
        signInWithPopup(clientAuth, new GithubAuthProvider())
          .then(onProviderSignIn)
          .catch((e) => {
            // Handle error
          });
      }}>
      Sign In with Google
    
  );
}

Elliot Hesp

CEO

Categories

Tags