MA
Matt Derman
Tech

How to implement SAML and OIDC SSO in Remix

How to implement SAML and OIDC SSO in Remix

SSO (Single-Sign-On) allows users to sign in to multiple services with a single login. If you use Gmail, you may have noticed that you don’t need to sign into Google Drive, or Google Photos. That’s SSO making your life easier!

Imagine that you’re part of a business that has 20+ internal apps (used by various staff who all have Microsoft Azure accounts), and you’ve also just onboarded a new client that will be using only some of these apps. These apps might contain confidential information or financial data. So you want to make sure that not everyone can access every app and have the same permissions, but you also don’t want to have to manage building out authentication systems separately for each app, managing users, sending password reset emails etc. With SSO, and a tool like Descope, all your users can sign-in in one place and be logged in to all yours apps everywhere.

In this article, you will setup OIDC SSO, SAML SSO, and magic link authentication using Descope that will secure a Remix app. The app we’ll be building is an AI chat application that generates images using OpenAI’s image API - and has light/dark mode using Shadcn. Here is what the app will look like:

A visual depiction of what is being written about

App Preview

Implementing authentication and SSO in Remix
Prerequisites

To follow along, you will need the following:

  • Npm and Git installed
Setting up Descope
Creating your Account

If you don’t have a Descope account already, click here to sign up. Otherwise, login to your account here.

Creating your Descope Project

If you have just created a new account, you will be redirected to the Getting Started page for your new default project.

If you want to create a new project, click on your project name in the top left (next to the Descope icon) and choose + Project from the dropdown. Creating a new project will take you to the Getting Started page.

Now that you are on the Getting Started page titled: Who uses your application, choose the Business option, since this will allow us to specify tenants for our SSO implementation later. Tenants represent entities such as “your employees” or “your client’s users” as mentioned in the example in the introduction.

Next, choose Magic-Link and SSO.

A visual depiction of what is being written about

Setup Options

On the page after that, choose “go ahead without MFA” as we don’t need this yet. Then click “next”. Now your project set up is complete, but you can edit the name of your project by going to SettingsProject in the sidebar on the left.

You will only be implementing and using the flow Sign up or in, so if you ever want to customize this flow, go to BuildFlowsSign up or in.

Customizing the Descope Flow

I have provided the flow template sign-up-or-sign-in.json at the root of the Git repository. To follow along with this tutorial, you will need to import this flow by going to BuildFlows, click on Import Flow and then select the json file.

A visual depiction of what is being written about

Import Flow

Creating the Remix app with authentication

In the provided repository, there are two branches: starter and master. Master is the completed project. In order to follow along with this tutorial, you must checkout the starter branch.

Once you are on the starter branch, let’s get familiar with the project. Have a look at the app/routes/_index.tsx file (which relates to our base route at http://localhost:3000) you will see a Remix loader is declared which asynchronously loads an example image. The component that consumes the previousChat is wrapped in a React Suspense, and just like that, we have data that displays in our app once it’s ready!

typescript
export async function loader() {
return defer({
previousChat: loadPreviousChat(),
});
}

In the same file, we declare the React component which will use the data (via the useLoaderData hook) that is asynchronously returned by the loadPreviousChat() function above.

typescript
const { previousChat } = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
const hasImageResult =
actionData && "imageUrl" in actionData && actionData.imageUrl;
return (
<SidebarProvider>
{isClient && <AppSidebar />}
<SidebarInset className="flex flex-col">
<Header />
<div className="flex flex-1 flex-col items-center justify-end p-4 pt-0">
<div className="w-full max-w-3xl mx-auto">
<PreviousExampleChatSession previousChatPromise={previousChat} />
{hasImageResult && ...

You will also notice in the above code that the app/components/nav/app-sidebar.client.tsx (AppSidebar) component is only rendered if isClient is true. This browser-only rendering logic is important, and you will use it later to render the app/components/auth/auth-wrapper.client.tsx

In this same app/routes/_index.tsx file there is a Remix action that will hit the OpenAI api and generate an image with the user’s prompt.

typescript
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const prompt = formData.get("prompt") as string;
if (!prompt) {
return json<ActionData>({ error: "Prompt is required" });
}
// ...

This action is called by a button inside the chat-command-menu.tsx, inside a Form element. By default in Remix, a button with type="submit" inside a Form, will send a HTTP POST to the action defined for that route. If you specify the “action” prop on the Form, it will use a different action. See resources.theme-toggle.tsx for an example of this.

This makes submitting data and forms really simple, but unfortunately it isn’t typed. You will see in the above code, that in order to access the prompt that the user submitted, you will have to access the value by typing in the name of key as a string and casting the result to string.

typescript
const prompt = formData.get("prompt") as string;

However, given how simple useLoaderData() makes managing state with Remix - this is easy to forgive.

What isn’t so simple, is securing this application so we can get real people using it with their own private AI chats…

Setting up Descope Authentication in Remix

Now it’s time to use the sign-up-or-sign-in.json flow that you imported into Descope earlier in our Remix application.

First, go to https://app.descope.com/settings/project and copy the Project ID. Then in your .env file in the repository, add PUBLIC_DESCOPE_PROJECT_ID={your_project_id}.

Then install:

shell
npm i --save @descope/react-sdk

Next, you will need to create the AuthWrapper component. This component displays the 3 different views our user will see depending on whether they are logged in, not logged in, or Descope is loading their session. Create a file at app/components/auth/auth-wrapper.client.tsx and paste in the below code:

typescript
import { AuthProvider, useSession, useUser, Descope } from "@descope/react-sdk";
import { Outlet } from "@remix-run/react";
import { toast } from "~/hooks/use-toast";
export function AuthWrapper({ children }: { children: React.ReactNode }) {
return (
<AuthProvider projectId={window.ENV.PUBLIC_DESCOPE_PROJECT_ID ?? ""}>
<AuthInner>
<Outlet />
</AuthInner>
</AuthProvider>
);
}
function AuthInner({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isSessionLoading } = useSession();
const { isUserLoading } = useUser();
return (
<>
{!isAuthenticated && (
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900 transition-colors">
<div className="w-full max-w-md p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-700">
<Descope
flowId="sign-up-or-in"
onSuccess={(e) => console.log(e.detail.user)}
onError={(e) =>
toast({
title: "Could not log in!",
description: "Please try again.",
variant: "destructive",
})
}
/>
</div>
</div>
)}
{isAuthenticated && (isSessionLoading || isUserLoading) && (
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900 transition-colors">
<div className="w-full max-w-md p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-700 text-center">
<p className="text-gray-700 dark:text-gray-300">Loading...</p>
</div>
</div>
)}
{isAuthenticated && !isSessionLoading && !isUserLoading && <Outlet />}
</>
);
}

Now you will need to change the app/root.tsx file: First add these imports to the top of the file.

typescript
import { AuthWrapper } from "./components/auth/auth-wrapper.client";
import { isClient } from "./lib/utils";

Change the environment variable in the loader (PUBLIC_DESCOPE_PROJECT_ID) so it will load your Descope project ID in into the client:

typescript
export const loader = async ({ request }: LoaderFunctionArgs) => {
return json({
requestInfo: {
hints: getHints(request),
userPrefs: {
theme: getTheme(request),
},
},
ENV: {
PUBLIC_DESCOPE_PROJECT_ID: process.env.PUBLIC_DESCOPE_PROJECT_ID,
},
});
};

The last thing to do in this file is scroll down to where you see <Outlet/> and replace it with:

typescript
{isClient && (
<AuthWrapper>
<Outlet />
</AuthWrapper>
)}

Now you will need to update the app/components/nav/app-sidebar.client.tsx so that this client-side navbar will load the user information from Descope once you have logged in.

Add this import to the file:

typescript
import { useUser } from "@descope/react-sdk";

Replace this user declaration starting on line 44:

typescript
const user = {
userId: "123",
email: "joe@example.com",
name: "Example User",
picture: "NA",
};

with the user that is obtained from the Descope useUser() hook:

typescript
const { user } = useUser();

The last thing to do is implement the logout functionality. Go to the app/components/nav/nav-user.client.tsx and add this import:

typescript
import { useDescope } from "@descope/react-sdk"

On line 38, add this code:

typescript
const { logout } = useDescope()
const handleLogout = useCallback(() => {
logout()
}, [logout])

The DropdownMenuItem near the bottom of the file currently has an empty/stub onClick event handler:

typescript
<DropdownMenuItem onClick={() => {}} className="flex items-center gap-2 text-destructive focus:text-destructive">
<LogOut className="size-4" />
<span>Log out</span>
</DropdownMenuItem>

Change this so that we use the new handleLogout function:

typescript
<DropdownMenuItem onClick={handleLogout} className="flex items-center gap-2 text-destructive focus:text-destructive">
<LogOut className="size-4" />
<span>Log out</span>
</DropdownMenuItem>

That’s it! If you startup the app now, it will display the Descope Welcome screen.

A visual depiction of what is being written about

Descope Welcome Screen

You can now sign in with a magic link, and the home screen will display your user information in the bottom left.

Lastly, if you want to access the token that Descope has provided your browser (after authenticating), you can access it like so. This example is from https://docs.descope.com/getting-started/react/nodejs:

typescript
import { getSessionToken } from '@descope/react-sdk';
const App = () => {
const { isAuthenticated, isSessionLoading } = useSession()
const { user, isUserLoading } = useUser()
const exampleFetchCall = async () => {
const sessionToken = getSessionToken();
// Example Fetch Call with HTTP Authentication Header
fetch('your_application_server_url', {
headers: {
Accept: 'application/json',
Authorization: 'Bearer ' + sessionToken,
}
})
}
// ...

Then, you can pass it in headers to your backend, or whatever else you may need to use it for. Thereafter, it can be verified using the Descope package for your backend. For instance, with Node.js install:

shell
npm i --save @descope/node-sdk

This is out of the scope of this tutorial, but you can read more at: https://docs.descope.com/getting-started/react/nodejs

Adding OIDC SSO with Descope and Azure

If you logout now, and try to login using the Continue with SSO button, you will notice it fails.

This is because, we need to setup a tenant for SSO in Descope, and then configure the application in Azure.

We are using Azure for this tutorial, but remember this could be done in Okta, Google Cloud etc.

Creating an application in Azure

Navigate to https://portal.azure.com/#view/Microsoft_AAD_IAM/AppGalleryBladeV2 to create a new application in Azure. Then click “Create your own application” in the top left, enter the name of the app and be sure to check the 2nd option as per below.

A visual depiction of what is being written about

Azure App Creation

On the following page, set the redirect uri as https://api.descope.com/v1/oauth/callback. Then go to the App Registration page and click on the app you just created. Then go to the Overview tab, and copy the Application (client) ID. Go to ManageCertificates & Secrets, then create a new client secret and copy the value.

Now that you’ve stored the Client ID and client secret, go to the Authentication tab directly above Certificates & Secrets, and check the 2 checkboxes - for access and ID tokens respectively. Then paste “https://api.descope.com/oauth2/v1/logout” into the Front-channel logout URL.

A visual depiction of what is being written about

Azure Authentication Settings

Now we have set up the app in Azure for our first tenant, but we don’t have the tenant modelled in Descope, so let’s create it.

Make the Descope Tenant

Go to https://app.descope.com/tenants and click the plus to create a new tenant.

Then navigate to the SSO page as indicated below, and select OIDC (Note - you can have OIDC and SAML enabled for a single tenant - so you could follow through this whole tutorial (with minor changes) and use the same tenant in Descope but different tenants in Azure.

A visual depiction of what is being written about

Descope SSO Configuration

Now scroll down to SSO Configuration and enter in this information under Account Settings:

  • Provider Name: Azure
  • Client ID: The application (client) ID you saved earlier
  • Client Secret: The secret you saved earlier
  • Scope: openid profile email
  • Grant Type: leave as Authorization code

To fill in the Connections Settings section underneath, you will need to head back to your app in Azure. Go the the Overview page again, and click on Endpoints tab at the top.

Fill in the following two inputs under Connections Settings in Descope using the value from the corresponding section in Azure:

  • Authorization Endpoint: The value under: OAuth 2.0 authorization endpoint (v2)
  • Token Endpoint: The value under: OAuth 2.0 token endpoint (v2)

Then, open the link under OpenID Connect metadata document in a new tab, and check “pretty print” so you can see the fields easier.

Now, you can fill in the last fields in Descope using the information on this page:

  • Issuer: the value to the right of issuer in the json
  • User Info Endpoint: the value for userinfo_endpoint
Time to Test

Now you can head back to the Remix app and make sure you’re logged out. Then click Sign in with SSO after entering the email associated with the domain of this new tenant you just configured.

If prompted, you will need to accept as per below:

A visual depiction of what is being written about

Azure SSO Consent

Now you will see your user in the sidebar, and so we’ve added OIDC SSO successfully!

A visual depiction of what is being written about

Successful SSO Login

Adding SAML SSO with Descope and Azure

In order to create a new SAML app within Azure, you may want to create a new tenant. This is because domains are bound to the tenant, so we can’t have users at different domains with the same tenant within Azure. Here’s how to create a new tenant.

Navigate to https://portal.azure.com/#view/Microsoft_AAD_IAM/DirectorySwitchBlade/subtitle/ and create a new tenant to use for this example. Choose Microsoft Entra External Id under Basics, and then enter your organization and domain name. Here is what I will be entering for this example:

A visual depiction of what is being written about

Azure Tenant Creation

Click Create, and switch to the tenant in the top right. Now you can create an application as per before with the OIDC SSO implementation, by going to your App Registrations, except choose the 3rd option after entering a name for your tenant.

A visual depiction of what is being written about

SAML App Registration

Go back to Descope and create a new tenant as per the OIDC section above. Remember, we need to make a tenant for each separate source/grouping of users. In this example I am imagining the tenant represents your employees, and the other tenant you created in Descope earlier representing users for one of your clients.

Go to the SSO page as per before (after selecting your new tenant here) https://app.descope.com/tenants, but enable SAML not OIDC. Now enter the domain for this tenant under SSO Domains within Tenant Details at the top of this page.

Scroll down to SSO Configuration. Then check Enter the connection details manually. Here’s what it will look like once you have entered all the information after the next few steps:

A visual depiction of what is being written about

SAML Configuration

To enter in that information pictured above that will allow Descope to connect to Azure, head back to the app registration in Azure. Go to ManageUsers and Groups+ Add user/group. Then click on the none selected option. Then select whatever users you want assigned to this tenant.

Now go to ManageSingle sign-on, and choose SAML. Scroll down to section 4. Then copy the values for Login Url, and Microsoft Entra Identifier, and paste them into the respective sections in Descope:

  • SSO (Single Sign-On) URL: Login Url
  • Entity ID: Microsoft Entra Identifier
A visual depiction of what is being written about

SAML URLs

Now you need to enter the Certificate in Descope. Go back to the Single sign-on page in Azure, and click download next to Certificate (Base64) in section 3. Open this file in a text editor. If you’re on a Mac, you may need to head to Settings → Privacy and Security, and scroll down to Security and check “Open Anyway”. Then copy the text and paste it into the Certificate input in Descope.

A visual depiction of what is being written about

Certificate Download

Finally, scroll down in Descope to the section under Service Provider. Go back to Azure, and scroll back up to section 1 on this same page, and click on Edit:

  • For: Identifier (Entity ID) paste in the value for Descope Entity ID (not required by all providers) from Descope
  • For: Reply URL (Assertion Consumer Service URL) paste in the value copied from Descope ACS URL in Descope
SSO Mapping for SAML SSO

The last thing we need to do is SSO mapping, this will ensure that Descope and our app can access attributes we might care about for our users - for example: assigned groups, roles, name, email etc. The SAML protocol does not contain something like a user info endpoint (like we provided earlier with OIDC) so we need to map this manually.

Go back to the Single sign-on page under Manage (that you were on earlier) and click Edit under section 2 (Attributes & Claims). Ensure that you have at least the below claims:

A visual depiction of what is being written about

SAML Claims

For instance, this is how the displayname claim would look like when clicking edit:

A visual depiction of what is being written about

Display Name Claim

Go back to Descope, and scroll down on the same SSO page until you reach the section for SSO Mapping. Enter these two mappings:

A visual depiction of what is being written about

SSO Mapping

Testing Time

Now you can login with SSO in the Remix app again, but make sure to use a user you have selected for this new tenant. If you see your name in the bottom left in the Remix app after logging in, you can be sure that the SSO mapping and other SAML configuration you entered was correct!

Optionally, you can head to https://app.descope.com/users and click edit on a user to confirm it was assigned to the correct tenant after logging in.

Conclusion

After reading this article, you should have a good understanding of:

  • Why SSO is useful (and what it is)
  • Securing pages (and possibly other APIs) in your Remix app
  • How to configure app registrations in Azure for OIDC and SAML
  • How to configure Remix for OIDC, SAML and magic link login

We have also explored the Descope Flow editor just enough for you to get a grasp of what is possible, and how you could begin to string together more advanced flows (think asking the user for MFA confirmation, filling out forms before signing up, the list goes on).

Descope’s SSO functionality has allowed you to centralize authentication, streamlining the authentication process for your users, while also allowing you to later manage roles and other authorization properties for your users all in one place. All of this functionality without you needing to implement SAML, OIDC / OAuth protocols into your app directly - saving you vast amounts of time and security concerns.

OLD

Systematize your audience growth as a solo founder

Sign up and get the Founder's Playbook to Growing your SaaS on Youtube (for free)!

Copyright © 2025 Friday Studios (Pty) Ltd
All rights reserved