Build a simple authentication process with Remix.run and Prisma
TLDR;
Why?
Restrict access to some functionalities
What?
A magic link like authentication
How?
Which tech?
Remix.run (full stack web framework)
Prisma (Node.js and TypeScript ORM)
bcrypt.js (Hashing library)
EmailJS (Transactional emails)
Full code available here 👇
https://github.com/schwepps/magiclink-auth
Disclaimer
Our restricted functionalities do not contain critical data. An unauthorized access would have no business consequences.
Choose your authentication method wisely!
Context
At Carbonable, we needed to find a simple way to restrict access to some functionalities without having to deal with user’s personal data such as email address.
We also wanted the flow to be as frictionless as possible for our user’s, quick to implement and easily upgradable to a stronger authentication method if needed in the future.
And last but not least, we wanted to keep our front-end stack based on Remix.run, Prisma and PostgreSQL.
The solution
Generate a unique access key and send a secure access link to authorized user by email.
Let’s code stuff!
Prerequisites
- Start a remix project
npx create-remix@latest
#Then choose the following options
? Where would you like to create your app? ./auth-app
? What type of app do you want to create? Just the basics
? Where do you want to deploy? Choose Remix App Server if you're unsure; it's easy to change deployment targets. Remix App Server
? TypeScript or JavaScript? TypeScript
? Do you want me to run `npm install`? Y
2. Install Tailwind for easy styling
3. Add Prisma to communicate with your database
# Install ts-node to execute scripts later
npm install ts-node @types/node --save-dev
# Install prisma
npm install prisma --save-dev
npm install @prisma/client
# Initialize prisma with you favorite supported datasource provider
npx prisma init --datasource-provider postgresql
In this example we use postgreSQL but you can install any supported database you like and follow the instructions in the prisma documentation.
4. Add bcrypt.js for hashing functionalities
npm install bcryptjs
5. Add EmailJS to send the access link to the user
- Create an account at https://www.emailjs.com/
- Select your email provider
- Create a template with the variables {{send_to}} to put as the recipient and {{access_token}} in the body of the email
- Install the npm package
npm install @emailjs/nodejs
# By default, API requests are disabled for non-browser applications.
# You need to activate them through Account:Security.
Create Prisma schema
Go to prisma.schema and complete the file with the following models:
// Access list
model AuthorizedDomain {
id String @id
}
//
model MagikLink {
id String @id @default(uuid())
createdAt DateTime @default(now())
hash String
isVerified Boolean @default(false)
email String
}
model Session {
id String @id @default(uuid())
createdAt DateTime @default(now())
data String
}
Create a db.server.ts in the utils folder:
import { PrismaClient } from "@prisma/client";
let db: PrismaClient;
declare global {
var __db: PrismaClient | undefined;
}
// this is needed because in development we don't want to restart
// the server with every change, but we want to make sure we don't
// create a new connection to the DB with every change either.
if (process.env.NODE_ENV === "production") {
db = new PrismaClient();
} else {
if (!global.__db) {
global.__db = new PrismaClient();
}
db = global.__db;
}
export { db };
Note 1: This file has a .server extension to indicate that it must be executed on the server side by Remix.
Run `npx prisma db push` to push the models to the database
Add an authorized domain into the AuthorizedDomain table in the database (ie. your-domain.com)
Create the app structure
To test our authentication we will need 2 pages:
- _index.tsx (created by default by remix) with a public access
- restricted.tsx with a restricted access
Edit _index.tsx
import type { V2_MetaFunction } from "@remix-run/node";
import { Link } from "@remix-run/react";
export const meta: V2_MetaFunction = () => {
return [
{ title: "My auth app" },
{ name: "description", content: "Welcome to my auth app!" },
];
};
export default function Index() {
return (
<div className="w-full max-w-5xl p-8">
<h1 className="font-bold text-4xl">Welcome to my auth app</h1>
<Link className="mt-4 underline hover:no-underline" to="/restricted">Go to private functionality</Link>
</div>
);
}
Note: The V2_MetaFunction is not necessary, it’s just to show how metadata for SEO works with Remix.
Create restricted.tsx in the routes folder
import { json, redirect, type LoaderArgs, type V2_MetaFunction } from "@remix-run/node";
import { Link } from "@remix-run/react";
import { getSession } from "utils/sessions.server";
export const meta: V2_MetaFunction = () => {
return [
{ title: "Restricted page" },
{ name: "description", content: "The restricted page of my auth app" },
];
};
export async function loader({ request }: LoaderArgs) {
const session = await getSession(request.headers.get("Cookie"));
const data = await session.get("data");
if (data === undefined) return redirect("/login");
// Try to parse the data
if (typeof data === "string") {
try {
if (!JSON.parse(data).data?.hasOwnProperty("userId")) return redirect("/login");
} catch (error) {
return redirect("/login");
}
}
return json({});
}
export default function Index() {
return (
<div className="w-full max-w-5xl p-8">
<h1 className="font-bold text-4xl">I'm a restricted access page</h1>
<Link className="mt-4 underline hover:no-underline" to="/">Go back to home</Link>
</div>
);
}
Note 1: The loader function is executed on the server side by Remix before the rendering.
Note 2: In the loader function we check if the user is authenticated with a session cookie.
If the user is authenticated he can access the page.
If not, he his redirected to the login page.
Note 3: There is currently an error because getSession doesn’t exist yet. Let fix that!
Manage the session
Create sessions.server.ts in the utils folder
import { createCookie } from "@remix-run/node";
import { createDatabaseSessionStorage } from "./dbsessions.server";
const sessionCookie = createCookie("__session", {
httpOnly: true,
path: "/",
secrets: process.env.SESSIONS_SECRETS !== undefined ? [process.env.SESSIONS_SECRETS] : [""],
sameSite: "lax",
secure: true
});
const { getSession, commitSession, destroySession } =
createDatabaseSessionStorage({ cookie: sessionCookie });
export { getSession, commitSession, destroySession };
Note 1: This file allows us to create a session cookie when the user is authenticated.
Note 2: As we want to handle the sessions in the database, we will use functions from createDatabaseSessionStorage. Let’s create this too!
Create dbsessions.server.ts in the utils folder
import { createSessionStorage } from "@remix-run/node";
import { db } from "./db.server";
// For more info check https://remix.run/docs/en/v1/api/remix#createsessionstorage
export function createDatabaseSessionStorage({ cookie }: any) {
return createSessionStorage({
cookie,
async createData(data, expires) {
const session = await db.session.create({
data: { data: JSON.stringify({ data }) },
});
return session.id;
},
async readData(id) {
return await db.session.findUnique({
where: { id },
});
},
async updateData(id, data, expires) {
await db.session.update({
where: { id },
data: { data: JSON.stringify({ data }) },
});
},
async deleteData(id) {
await db.session.delete({ where: { id } });
},
});
}
Note 1: The createDatabaseSessionStorage function take a cookie as a parameter and returns generic session storage object from Remix.
Note 2: The session storage must have a createData, a readData, an updateData and a deleteData async function. Each of these function uses the session table created in the prisma model earlier.
Allow users access the restricted page
Now that we can handle the session, let’s create the login page where the user is automatically redirected to when he tries to access the restricted page.
Create the access page _auth.login.tsx
import type { LoaderArgs} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useFetcher } from "@remix-run/react";
import { useEffect, useState } from "react";
import { getSession } from "utils/sessions.server";
export async function loader({ request }: LoaderArgs) {
const session = await getSession(request.headers.get("Cookie"));
if (session.has("userId")) return redirect("/");
return json({});
}
export default function Login() {
const magikLinkFetcher = useFetcher();
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
useEffect(() => {
setIsLoading(magikLinkFetcher.state === "loading" || magikLinkFetcher.state === "submitting");
setIsSuccess(magikLinkFetcher.state === "idle" && magikLinkFetcher.data?.hasOwnProperty("error") === false);
setIsError(magikLinkFetcher.state === "idle" && magikLinkFetcher.data?.hasOwnProperty("error") === true);
}, [magikLinkFetcher]);
return (
<div className="w-full md:w-11/12 mx-auto flex h-[calc(100vh_-_80px)] justify-center items-center">
<div className="text-center w-full md:w-1/2">
<h1 className="text-3xl font-bold uppercase">Get access</h1>
<magikLinkFetcher.Form method="post" action="/login/magiklink" className="mt-8 w-full">
<input type="email" name="email" id="email" placeholder="Enter your email address" className="text-black border border-stone-400 rounded-xl outline-0 w-full px-4 py-2 mt-1 bg-transparent focus:border-stone-500" />
{ isError === true && <div className="text-sm text-center text-red-800 w-full">{magikLinkFetcher.data?.error}</div>}
{ isLoading === false && isSuccess === false &&
<button className="border border-neutral-300 text-white bg-emerald-500 uppercase px-4 py-2 rounded-lg hover:bg-emerald-500/95 mt-4">Receive my access link</button>
}
{ isLoading === true &&
<div className="border border-neutral-700 bg-emerald-500 uppercase px-4 py-2 rounded-lg mt-4 flex justify-center items-center cursor-not-allowed">
<svg className="animate-spin mr-3 h-5 w-5 text-white fill-none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Sending my access link...
</div>
}
{ isSuccess === true &&
<div className="border border-stone-700 bg-emerald-500 uppercase px-4 py-2 rounded-lg mt-4 flex justify-center items-center">
Access link successfully sent!
</div>
}
</magikLinkFetcher.Form>
</div>
</div>
)
}
Note 1: The name of the file is _auth.login.tsx, which means that the url of the page will be /login.
The _ allows to regroup routes without having the “auth” part in the url in our case. A file named auth.login.tsx would have the url /auth/login.
Note 2: In the loader we are preventing users to generate infinite access links.
Note 3: We are using a fetcher which allows us to submit the form asynchronously without reloading or redirecting when the form is submitted.
Note 4: In our example we do not have any client side form validation and are just checking in the useEffect the state of the fetcher and if the fetcher returns an error. More validation or complex logic could be added.
Note 5: The form has a post action to /login/magiklink, which means that we need to create a file representing this route.
Create the _auth.login.magiklink.tsx
import { json, type ActionArgs } from "@remix-run/node";
import { db } from "utils/db.server";
import { sendMagikLink } from "utils/magikLink.server";
import bcrypt from "bcryptjs";
function validateEmail(email: string | undefined): boolean {
if (undefined === email) { return false; }
// eslint-disable-next-line no-useless-escape
const mailformat = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
return email.match(mailformat) ? true : false;
}
/**
* Create a magik link and send it to the user's email address
*
* @param { ActionArgs } request
* @returns { json } Returns a json containing either the error message or the success message
*/
export async function action({ request }: ActionArgs) {
const formData = await request.formData();
const email = formData.get("email");
// Check if the email is valid
if (!email || !validateEmail(email.toString())) return json({error: "Please enter a valid email address"}, {status: 400});
// Check if the email is part of authorized emails
const authorizedDomains = await db.authorizedDomain.count({
where: {
id: email.toString().split("@")[1]
}
});
if (authorizedDomains === 0) return json({error: "This email address is not authorized to access this application"}, {status: 400});
// Check if the email is already in the database and if it is not verified
const hashedEmail = bcrypt.hashSync(email.toString(), process.env.HASH_SECRET);
const result = await db.magikLink.findFirst({
where: {
email: hashedEmail,
isVerified: false
}
});
// If it is, update the magik link and send it again
if (result) {
await db.magikLink.update({
where: {
id: result.id
},
data: {
isVerified: true
}
})
}
// Send the magik link
return await sendMagikLink({request, email: email.toString()});
}
Note 1: This route only contains an action function which is the other type of server side function in Remix with the loader function.
Note 2: The email validation is done on the server side here but could have been done on the client side in _auth.login.tsx also.
Note 3: If the email is not part of a domain in the access list, no access link is sent.
Note 4: As we do not want to store plain emails in our database, the emails are hashed with bcrypt. You need to generate a salt and store it as an env variable (process.env.HASH_SECRET).
Note 5: We only allow one active access link per user.
If the user has already an active access link, it’s deactivated.
Note 6: If all the conditions are fulfilled, a new access link will be generated and sent to the user in the sendMagikLink async function.
Create the /utils/magikLink.server.ts file
import { json } from "@remix-run/node";
import { randomBytes } from "crypto";
import { db } from "./db.server";
import bcrypt from "bcryptjs";
import { sendEmail } from "./emails.servers";
export async function sendMagikLink({request, email}: {request: Request, email: string}) {
// Generate a random hash
const accessKey = randomBytes(32).toString('hex');
const hashedEmail = bcrypt.hashSync(email, process.env.HASH_SECRET);
// Save the hash in the database
try {
await db.magikLink.create({
data: {
hash: accessKey,
email: hashedEmail,
}
});
} catch (error) {
return json({error: "Error saving the link in the database. Please try again."}, {status: 500});
}
// Build the access link
const magikLink = new URL(request.headers.get("origin") || "");
magikLink.pathname = "/login/verify";
magikLink.searchParams.set("hash", accessKey);
// Send the email
const result = await sendEmail({email, accessLink: magikLink.href});
if (result?.status !== 200) {
return json({error: "Error sending the email. Please try again."}, {status: 500});
}
return json({success: "Email sent"}, {status: 200})
}
Note 1: The accessKey is a 32 hexadecimal string using randomBytes of the node crypto librairy.
Note 2: The new accessKey is saved in the database.
Note 3: The access link sent to the user is a URL to /login/verify with the accessKey as a parameter. Using request.headers.get(“origin”) to build the URL ensure that it will work on any platform even localhost.
Note 4: The email is sent from the sendEmail async function.
Create the /utils/emails.server.ts file
import emailjs, { EmailJSResponseStatus } from '@emailjs/nodejs';
import { json } from '@remix-run/node';
export async function sendEmail({email, accessLink}: {email: string, accessLink: string}) {
if (process.env.MAILJS_SERVICE_ID === undefined || process.env.MAILJS_TEMPLATE_ID === undefined || process.env.MAILJS_PUBLIC_KEY === undefined || process.env.MAILJS_PRIVATE_KEY === undefined) {
return;
}
const templateParams = {
to_email: email,
access_link: accessLink,
};
try {
await emailjs.send(
process.env.MAILJS_SERVICE_ID,
process.env.MAILJS_TEMPLATE_ID,
templateParams,
{
publicKey: process.env.MAILJS_PUBLIC_KEY,
privateKey: process.env.MAILJS_PRIVATE_KEY, // optional, highly recommended for security reasons
},
);
return json({success: "Email sent"}, {status: 200})
} catch (err) {
if (err instanceof EmailJSResponseStatus) {
return json({error: err.text}, {status: err.status})
}
}
}
Note 1: Check that all the needed env variables are set. The four needed variables can be found in your emailJS account.
Note 2: We need to set the params used in the email. When we created the template, we setup 2 params: to_email for the recipient and access_link with the link to access the restricted page.
Note 3: Now that the user has received his private access, we need to verify the access key to unlock the restricted page.
Create the _auth.login.verify.tsx page
import { redirect, type LoaderArgs } from "@remix-run/node";
import { db } from "utils/db.server";
import { commitSession, getSession } from "utils/sessions.server";
export async function loader({ request }: LoaderArgs) {
// If cookie has a userId, redirect to the home page
const session = await getSession(request.headers.get("Cookie"));
if (session.has("userId")) return redirect("/restricted");
// Check if the magik link exists and is not verified
const magikLink = await db.magikLink.findFirst({
where: {
hash: new URL(request.url).searchParams.get("hash") || "",
isVerified: false
}
});
if (!magikLink) return redirect("/login");
// Activate the magik link
await db.magikLink.update({
where: {
id: magikLink.id
},
data: {
isVerified: true
}
});
// Create a session
session.set("userId", magikLink.hash);
const expires = new Date();
const MAX_AGE = process.env.SESSION_EXPIRATION_IN_SECONDS !== undefined ? parseInt(process.env.SESSION_EXPIRATION_IN_SECONDS) : 10;
expires.setSeconds(expires.getSeconds() + MAX_AGE);
return redirect("/restricted", {
headers: {
"Set-Cookie": await commitSession(session, {expires}),
},
});
}
Note 1: This page only contains a loader function and redirect either to /login if the access key is not valid or to the restricted page if the user has already access or the access key is valid.
Note 2: If the access link is valid, we invalidate it to prevent another access though the same link.
Note 3: If the link is valid a session cookie is created using the commitSession function we created in sessions.server.ts
Note 4: The expiration of the cookie is set as an env variable so you can manage it per environment. Here for testing purposes it’s 10 seconds.
Congrats 🥳
You’ve made it to the end!
You can just run npm run dev in your terminal to try it.
Going further
This technical stack could be easily enriched with some useful functionalities or upgraded to a more robust authentication solution.
Here is some thoughts:
- Add a redirectTo parameters in the accessLink to remember which page the user was trying to access.
- Build a common email/password login form in _auth.login.tsx and save the hashed email and password in the database. Use the email flow to verifiy the email or to build a “forgot my password” flow.