Skip to main content


Let's look at how to authenticate users in a remix app using SAML Single-Sign-On (SSO).

If you wish to dive straight into the source, Checkout:

App Setup

Create remix app

npx create-remix@latest 

You can go with the Remix App Server as the deployment target. Feel free to choose either 'Typescript' or "Javascript". All the code samples in this guide are in typescript.

Install the dependencies

npm i remix-auth @boxyhq/remix-auth-saml

remix-auth is a complete open-source authentication solution for Remix applications. @boxyhq/remix-auth-saml provides a remix-auth strategy to interact with the SAML Service provider.


First, we need an Authenticator instance from remix-auth. Authenticator exposes the API for login and logout.

Create sessionStorage for Authenticator


NOTE: We will be relying on cookie-based sessions See: createCookieSessionStorage from remix.

import { createCookieSessionStorage } from "remix";

const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
path: "/",
sameSite: "lax",
secrets: process.env.COOKIE_SECRETS!.split(","),
secure: process.env.NODE_ENV === "production",

const { getSession, commitSession, destroySession } = sessionStorage;
const JACKSON_ERROR_COOKIE_KEY = "jackson_error";

export default sessionStorage;
export { getSession, commitSession, destroySession, JACKSON_ERROR_COOKIE_KEY };

Create the Authenticator instance


NOTE: We haven't initialised the strategy use yet. That will be done in following sections

import { Authenticator } from "remix-auth";
import {
type BoxyHQSAMLProfile,
} from "@boxyhq/remix-auth-saml";
import invariant from "tiny-invariant";
import sessionStorage from "./sessions.server";

let auth: Authenticator;
declare global {
var __auth: Authenticator | undefined;

function createAuthenticator() {
const auth = new Authenticator<BoxyHQSAMLProfile>(sessionStorage);

// Strategy use for the hosted saml service provider goes here

// Strategy use for the embedded saml service provider goes here

return auth;

if (process.env.NODE_ENV === "production") {
auth = createAuthenticator();
} else {
// In development we don't want to recreate the Authenticator for every change
if (!global.__auth) {
global.__auth = createAuthenticator();
auth = global.__auth;

export { auth };

Strategy Usage

Our strategy usage depends on how we integrate the SAML Service Provider into the app. With SAML Jackson Provider you've got 2 options up your sleeve.

  1. Host SAML SP as a separate service.
  2. Embed SAML SP functionality leveraging remix resource routes.


To get going, you'll need a hosted instance of "SAML Jackson".
Refer to the documentation in case you're planning to deploy Jackson to your favorite hosting provider.
Otherwise, fret not 🤗, we have a hosted instance of Jackson, that can be readily used without any configuration.

Initialise Strategy

Use the strategy with the Authenticator as shown below.The clientID/Secret values are expected to be set dynamically from the client side. For now set them to the value dummy.

Point the issuer to the jackson service url.


invariant(process.env.BASE_URL, "Expected BASE_URL to be set in env");
"Expected BOXYHQSAML_ISSUER to be set in env"

const BASE_URL = process.env.BASE_URL;
// Strategy use for the hosted saml service provider goes here
new BoxyHQSAMLStrategy(
clientID: "dummy",
clientSecret: "dummy",
callbackURL: new URL("/auth/saml/callback", BASE_URL).toString(),
async ({ profile }) => {
return profile;


We need 2 routes:

~> /auth/saml - Action handler for login
~> /auth/saml/callback - After successful authorization, user is redirected here with the authorization code. The code is then exchanged to get the token and further the user profile.

Create the following files under app/routes:


export const action: ActionFunction = async ({ request }) => {
const formData = await request.formData();
const email = formData.get("email");
const product = formData.get("product");

... // Add some validation logic

// extracting the tenant from email is one way to set it
const tenant = email.split("@")[1];

return await auth.authenticate("boxyhq-saml", request, {
successRedirect: "/private",
failureRedirect: "/login",
context: {
clientID: `tenant=${tenant}&product=${product}`,
clientSecret: "dummy",


export const loader: LoaderFunction = async ({ request, params }) => {
return auth.authenticate("boxyhq-saml", request, {
successRedirect: "/private",
failureRedirect: "/login",

SAML configuration

client_id         :
Identity Provider :

We'll be using the above pre-configured tenant/product pointing to as the IdP.

App routes


For the Login page we need a form that can accept email (for deriving tenant) and product. We also change the form action for the embedded SAML provider button.


  export const loader: LoaderFunction = async ({ request }) => {
// check if authenticated then redirect to /private
await auth.isAuthenticated(request, { successRedirect: "/private" });
// return form error data from session

export default function Login() {
return (
... // input fields for email and product
<button type="submit">Continue with SAML SSO (Hosted SAML Provider)</button>
<button type="submit" formAction="/auth/saml/embed">Continue with SAML SSO (Embedded SAML Provider)</button>


 export const loader: LoaderFunction = async ({ request }) => {
await auth.logout(request, { redirectTo: "/login" });


This page renders the logged-in user profile.


  export const loader: LoaderFunction = async ({ request }) => {
const profile = await auth.isAuthenticated(request, {
failureRedirect: "/login",

return json<LoaderData>({ profile });

export default function Private() {
const { profile } = useLoaderData<LoaderData>();
return (
<h1 className="text-primary mb-4 font-bold md:text-3xl">Raw profile</h1>
<code>{JSON.stringify(profile, null, 2)}</code>

Error page (needed only for embedded SAML SP)

For errors occuring in the SAML flow (/api/oauth/authorize and /api/oauth/saml), the user get's redirected to an error page.

Create one at /error:


export const loader: LoaderFunction = async ({ request }) => {
const session = await getSession(request.headers.get("Cookie"));
const { statusCode, message } = session.get(JACKSON_ERROR_COOKIE_KEY) || {
statusCode: null,
message: "",
return json({ statusCode, message });

export default function Error() {
const { statusCode, message } = useLoaderData<LoaderData>();

let statusText = "";
if (typeof statusCode === "number") {
if (statusCode >= 400 && statusCode <= 499) {
statusText = "client-side error";
if (statusCode >= 500 && statusCode <= 599) {
statusText = "server error";

if (statusCode === null) {
return null;

return (
<div className="h-full">
<div className="h-[20%] translate-y-[100%] px-[20%] text-[hsl(152,56%,40%)]">
className="mb-5 h-10 w-10"
viewBox="0 0 24 24"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
<h1 className="text-xl font-extrabold md:text-6xl">{statusCode}</h1>
<h2 className="uppercase">{statusText}</h2>
<p className="mt-6 inline-block">SAML error: </p>
<p className="mr-2 text-xl font-bold">{message}</p>

Ready to go

At this stage you are ready to accept SAML users into your app. 🎉

Next steps