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:
- The user enters their email & password into the form.
- The user presses the “submit” button.
- The submission is intercepted via the
handleSubmit
function. - The email & password is extracted the form event.
- The
signInWithEmailAndPassword
function is called to attempt to authenticate the user. - On success, the
getIdToken
function is called to get the user’s idToken. - The
idToken
is sent via a POST request to our action. - 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
);
}