I've recently discovered Descope, a new auth provider service with a great developer experience. They aim to greatly simplify authentication and authorization implementation for developers. They have a generous free plan for 7,500 MAUs, which convinced me to try them out on my Next.js app.
Let's learn a bit about Descope and how to use it with Auth.js (next-auth) to protect our Next.js app with role-based access control (RBAC).
Hello Descope
Descope is a developer-friendly authentication and user management platform that improves account security and UX by focusing on passwordless authentication. It supports a variety of methods like magic links, biometrics, and social logins for more secure, accurate, and user-friendly authentication.
In addition to enhancing UX, Descope also improves DX by providing a drag-and-drop visual interface to create and customize login flows for your app. The workflows (called Descope Flows) can be embedded in an app with a few lines of code. Additionally, you don’t need to touch your codebase while making changes to your login process – just change the Descope Flow and your app is updated in real time.
What I Liked About Descope
Onboarding to a new project is seamless - You can easily set up the whole process with just a few clicks, including choosing various auth methods. The flows are created for you automatically.
You can control every single piece of the auth lifecycle, with a clever and convenient workflow editor
A lot of things that may be cumbersome with other auth providers, are solved with one click in Descope. The best example of this is unifying accounts across multiple auth methods by email, which in some other platforms requires writing custom code.
Starting a New Descope Project
In this sample, I've followed these guidelines:
Set my application as
Consumers Application
Chose
Social Login
andMagic Link
as the mainauthentication methods
-
Chose
One Time Password
asMFA method
After the onboarding process:
Added
Google
as social login, and made sure I turned onMerge user accounts based on email address
Created an
Access Key
to use in our Next.js appThe
Project ID
can be found under the mainIDP application
in theFlow Hosting URL
Setting up Descope With Our Next.js App
Following the Descope quick start guide is the best way to setting up our Next.js app:
First, install Auth.js with: npm i next-auth
Next, create the main endpoint for the auth process. Auth.js will take over all the sub-routes. In this endpoint, we set Auth.js to take over from Descope as an auth provider. The file is app/api/auth/[...nextauth]/route.ts
:
import NextAuth from "next-auth/next";
import { NextAuthOptions } from "next-auth"
export const authOptions: NextAuthOptions = {
providers: [
{
id: "descope",
name: "Descope",
type: "oauth",
wellKnown: `https://api.descope.com/<Descope Project ID>/.well-known/openid-configuration`,
authorization: { params: { scope: "openid email profile" } },
idToken: true,
clientId: "<Descope Project ID>",
clientSecret: "<Descope Access Key>",
checks: ["pkce", "state"],
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
}
},
}]
}
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
For the client side, the access to the session and the logged-in user data is provided through the Auth Provider. The way to do this is to create a client component
that exports the default Auth.js Auth Provider
. That way, other client components wrapped with this provider will have access to the useSession
hook.
Next up, create the AuthProvider
client component with this code...
'use client'
import { SessionProvider } from "next-auth/react"
export default function NextAuthSessionProvider(
{ children }:
{ children: React.ReactNode }
) {
return (
<SessionProvider>
{ children }
</SessionProvider>
)
}
...and wrap up the body in the root layout.tsx
file with the new provider:
import NextAuthSessionProvider from './provider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<NextAuthSessionProvider>
<div>
{children}
</div>
</NextAuthSessionProvider>
</body>
</html>
)
}
Sign In & Sign Out
The Sign In & Sign Out processes will be initiated by a button
. These buttons should live in a client context, therefore their wrapper should be tagged as use client
.
My wrapper component is the Navbar
. There, I can get the session with the useSession
hook, check the auth status (authenticated/unauthenticated), and present the right button. Let's see it in action:
"use client";
import { signIn, signOut, useSession } from "next-auth/react";
const Navbar = () => {
const session = useSession();
return (
//...
{session.status === "unauthenticated" && (
<li>
<button className="text-white" onClick={() => signIn("descope")}>
Log In
</button>
</li>
)}
{session.status === "authenticated" && (
<li>
<button className="text-white" onClick={() => signOut()}>
Log Out
</button>
</li>
)}
//...
);
};
export default Navbar;
Role-Based Access Control (RBAC)
Descope supports three levels of RBAC: Tenant, Role, and Permission. Tenant is mainly for separating users' authorizations to one or more sub-platforms on your applications (for example, for different clients or customers), mainly for B2B apps or services. In our example we won't use tenancy, as we're building a B2C app.
You can quickly attach these access control properties to a user with Descope's SDK or through the console, see Tenants
and Authorization
in the main navbar, and after that pick a user through Users
, and attach a role to the user.
Roles (and tenants in B2B) are part of the claims data we get in the profile
from Descope. You can attach to the next-auth session even more details, and even add custom claims as a part of Descope's flow.
Let's see how to get the user's role on our Auth.js session. We need to modify a few things in the [...nextauth]/route.ts
file.
-
Add
descope.claims
as a scope param, under the provider authorization:
params: { //Add descope.claims scope: "openid email profile descope.claims", },
-
The
profile
function under theprovider
settings serves as a tunnel between the OAuth profile (in our case, Descope), and the next-auth's User structure. In our case, we need to get the roles fromdescope.claims
:
profile(profile) { return { ... roles: profile.roles, //Add this line }; },
-
Right after the providers' array, add a
callbacks
object, and write two simple callback functions.-
jwt
- Here we can add data to the token. This callback is called on every JWT creation or update (i.e. whenever a session is accessed in the client). The return value will be encrypted and saved as a cookie. In our case, we will burn the roles on the JWT. -
session
- The session callback is called whenever a session is checked. Here we can add more data to our session, and we can take the roles from the token into the session's user data.
Additional documentation about the callbacks can be found here.
-
providers: [
{
//Descope Provider
},
],
callbacks: {
async jwt({ token, profile }) {
if (profile) {
token.roles = profile.roles;
}
return token;
},
async session({ session, token }) {
if (session.user) session.user.roles = token.roles;
return session;
},
},
As a tip, you can add a nextauth.d.ts
file to your codebase to include the new attributes. Typescript will thank you for that :)
import "next-auth";
import "next-auth/jwt";
type AuthRole = "Admin" | "Client";
declare module "next-auth" {
interface User {
roles: AuthRole[];
}
interface Session {
user: User;
}
interface Profile {
roles: AuthRole[];
}
}
declare module "next-auth/jwt" {
interface JWT {
roles: AuthRole[];
}
}
Client-Side & Server-Side Authorization
Now you can protect your app, and make sure that the pages/routes will be accessible just to the right user with the right authorization.
On the client side (client components), use the
useSession
hook, and see ifsession.state === "authenticated"
and ifsession.user.roles
includes the right role. See the docs. See the docs for more details.You can similarly use the
getServerSession
function on the server side (server components and routes). See the docs for more details.You can even maintain a
middleware
for stricter maintenance. In the middleware, just the JWT is exposed and not the whole session. See the docs for more details.
You can see my implementation in the demo app, auth
branch.
Descope's Connectors
What I like in Descope's experience is the seamless integration with 3rd parties, via the Connectors. These services could be Hubspot, Twillio, Datadog, and others.
Without even a line of code, you can use a wide variety of connectors in your flows. Here's a simple example of adding reCAPTCHA to the sign up or in
flow:
Give It a Try!
I tried here to demonstrate the DX of using Descope as an auth provider in a Next.js app. I was impressed by the smooth experience, as well the unique security functionalities they’ve added to take the burden off developer shoulders.
My demo app is available on GitHub. Make sure you have 2 branches there: main
for the app without auth processes, and auth
with Descope & Auth.js as RBAC auth is demonstrated there.
Top comments (1)
Great post!