Next.js Passwordless Authentication with SuperTokens & Twilio
- Share:
TL;DR: Passwordless authentication replaces traditional login methods with secure alternatives, like one-time passwords or biometrics, simplifying user experience. As a Developer Advocate at Permit.io, I encourage developers to learn this process through code and practical examples.
In this tutorial, we’ll learn how to set up SuperTokens Passwordless Authentication and How to implement basic Authorization with Permit.io.
What is Passwordless Authentication
Passwordless authentication is a method of verifying a user’s identity without relying on traditional username/password combinations. Instead, it uses alternative means, such as one-time codes, magic links sent via email, or biometric data like fingerprints or facial recognition. This approach simplifies the login process and enhances security by eliminating the need to remember and manage passwords.
If you still find yourself confused about the difference between Authentication and Authorization (as they sound very similar), check out this great article.
SuperTokens is an open-source authentication provider designed to simplify adding secure, scalable, and easily customizable authentication to your applications. It provides various features like passwordless login, email verification, OAuth support, and session management.
Next.js is a popular React framework built by Vercel, which provides features such as server-side rendering (SSR), static site generation (SSG), and API routes. It’s designed to make building scalable, high-performance web applications easier while offering a rich developer experience.
Prerequisites: Basic knowledge of NextJS and JavaScript
Let’s create a basic starter App in Next.js!
As a Developer Advocate at Permit.io, I have consistently been guided to believe that when developers aspire to navigate the intricate realm of Authentication and Authorization, the most effective approach is to demonstrate them through code and practical examples.Â
Let’s dive into that.
To get started, you’ll need to have Node.js installed on your computer. Then, follow these steps:
- Install the
create-next-app
CLI tool globally.
npm install -g create-next-app
2. Create a new Next.js application. You will be prompted with several questions to setup your project.
create-next-app my-supertokens-app
3. Navigate to the newly created directory.
cd my-supertokens-app
4. Run the development server.
npm run dev
Now you should see your Next.js application running on http://localhost:3000
.
Congratulations!
Setting up SuperTokens Passwordless Authentication
This guide will cover a few steps:
- Creating our frontend and backend configurations
- Showing the Login UI
- Configuring Twilio and setting up SMS OTPs
- Adding SMS Delivery Functionality
- Adding Authentication APIs
- Protecting restricted website routes with session verification
- Implementing basic authorization with Permit
For us to get started, we need to make sure we have the required dependencies installed  — 
npm install supertokens-node@latest supertokens-auth-react@latest supertokens-web-js@latest nextjs-cors dotenv swr
1. Creating our frontend and backend configurations
- Create anÂ
.env.local
file at the root of the project with the following content. We will fill these in as we proceed with the tutorial.
SUPERTOKENS_CONNECTION_URI=
SUPERTOKENS_API_KEY=
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_TRIAL_PHONE_NUMBER=
- Create a
config
folder at the root of your project. - Now create an
appInfo.js
inside theconfig
folder with the following content:
const port = process.env.APP_PORT || 3000;
const apiBasePath = "/api/auth/";
export const websiteDomain =
process.env.APP_URL ||
process.env.NEXT_PUBLIC_APP_URL ||
`http://localhost:${port}`;
export const appInfo = {
appName: "SuperTokens with Passwordless by Permit.io",
websiteDomain,
apiDomain: websiteDomain,
apiBasePath,
};
The appInfo
file is crucial for implementing SuperTokens Passwordless Authentication. It specifies essential details such as the app’s name, API domain, and website domain. These configurations enable SuperTokens to correctly manage sessions, cookies, and CORS settings. This ensures seamless integration and secure authentication for the frontend and backend. You can read more about the appInfo
file here.
We need two more configuration files in our config
folder. One for the frontend and one for the backend.
- Create a
frontendConfig.js
file inside theconfig
folder.Â
We need to construct a function that generates the SuperTokens’ front-end SDK configuration. We will explore how to utilize this function later. Take note of the contact method, PHONE
, which signifies that passwordless authentication will be facilitated through sending an OTP only to the email or phone number specified by the user during login. You can also specify PHONE_OR_EMAIL
to have the flexibility of authenticating with a Magic Link too. In this tutorial, we will only allow passwordless login with the aid of a phone.Â
import { PasswordlessPreBuiltUI } from "supertokens-auth-react/recipe/passwordless/prebuiltui";
import SessionReact from "supertokens-auth-react/recipe/session";
import { appInfo } from "./appInfo";
import Router from "next/router";
import PasswordlessReact from "supertokens-auth-react/recipe/passwordless";
export let frontendConfig = () => {
return {
appInfo,
recipeList: [
PasswordlessReact.init({
contactMethod: "PHONE",
}),
SessionReact.init(),
],
// this is so that the SDK uses the next router for navigation
windowHandler: (oI) => {
return {
...oI,
location: {
...oI.location,
setHref: (href) => {
Router.push(href);
},
},
};
},
};
};
export const PreBuiltUIList = [PasswordlessPreBuiltUI];
Now let’s create the back-end configurations -Â
- Create a file called
backendConfig.js
inside theconfig
folder.
require("dotenv").config();
import SessionNode from "supertokens-node/recipe/session";
import Dashboard from "supertokens-node/recipe/dashboard";
import { appInfo } from "./appInfo";
import PasswordlessNode from "supertokens-node/recipe/passwordless";
export let backendConfig = () => {
return {
framework: "express",
supertokens: {
// this is the location of the SuperTokens core.
connectionURI:
process.env.SUPERTOKENS_CONNECTION_URI,
apiKey: process.env.SUPERTOKENS_API_KEY,
},
appInfo,
// recipeList contains all the modules that you want to
// use from SuperTokens. See the full list here: https://supertokens.com/docs/guides
recipeList: [
PasswordlessNode.init({
flowType: "USER_INPUT_CODE",
contactMethod: "PHONE",
}),
SessionNode.init(),
Dashboard.init(),
],
isInServerlessEnv: true,
};
};
To properly configure your application, you must set up your own SuperTokens core and replace the core address above.
There are two methods for setting up your own core:
- Self-hosting SuperTokens: You can host the SuperTokens core on your own infrastructure, utilizing your database of choice (MySQL or PostgreSQL). This can be done through manual installation or by using Docker.
- Using the managed service option: To establish a SuperTokens core via the managed service, create a free account with Supertokens and log in. From the dashboard, create a new app. You can obtain the connection URL and API key from the app details page upon completion.
For this example, I went for the managed service option. It’s fast and easy to get started. This is what you should see in the SuperTokens dashboard once you create an account, and complete the simple “Get Started” section:
(!) Make sure that the Development Environment is version v5.0
.
Under setup
you will be able to find the URL
and the API Key
. Please make sure you add them to the .env.local
file.
Next, we’ll need to call the front-end initialization functions from the _app.js
file. To do this, follow these steps:
- Locate the
_app.js
file inside thepages
folder. - Replace its content with the provided code snippet.
import "../styles/globals.css";
import React from "react";
import { useEffect } from "react";
import SuperTokensReact, { SuperTokensWrapper } from "supertokens-auth-react";
import * as SuperTokensConfig from "../../config/frontendConfig";
import Session from "supertokens-auth-react/recipe/session";
if (typeof window !== "undefined") {
SuperTokensReact.init(SuperTokensConfig.frontendConfig());
}
function MyApp({ Component, pageProps }) {
useEffect(() => {
async function doRefresh() {
if (pageProps.fromSupertokens === "needs-refresh") {
if (await Session.attemptRefreshingSession()) {
location.reload();
} else {
// user has been logged out
SuperTokensReact.redirectToAuth();
}
}
}
doRefresh();
}, [pageProps.fromSupertokens]);
if (pageProps.fromSupertokens === "needs-refresh") {
return null;
}
return (
<SuperTokensWrapper>
<Component {...pageProps} />
</SuperTokensWrapper>
);
}
export default MyApp;
Congratulations — All the integrations are now complete. Let’s now display a Login UI.
2. Showing the Login UI
Adding the UI is simple.Â
- Create a new folder called
auth
inside thepages
folder. - Inside the
auth
folder, create a file called[[...path]].js
. This naming represents a catch-all dynamic route. - Paste the below code into the
[[...path]].js
file.
import React, { useEffect } from "react";
import dynamic from "next/dynamic";
import SuperTokens from "supertokens-auth-react";
import { canHandleRoute, getRoutingComponent } from "supertokens-auth-react/ui";
import { PreBuiltUIList } from "../../../config/frontendConfig";
const SuperTokensComponentNoSSR = dynamic(
new Promise((res) => res(() => getRoutingComponent(PreBuiltUIList))),
{ ssr: false }
);
export default function Auth() {
useEffect(() => {
if (canHandleRoute(PreBuiltUIList) === false) {
SuperTokens.redirectToAuth({
redirectBack: false,
});
}
}, []);
return (
<main>
<SuperTokensComponentNoSSR />
</main>
);
}
Navigate to your http://localhost:3000/auth
. You should see the passwordless login screen.
In general, authentication serves the purpose of restricting users from accessing certain parts of a site. It might seem that we are good to go, but we haven’t yet secured any other part of the URL. We will do that in a later part of the tutorial. Let’s now set up our Twilio account, configure the phone numbers and add our private keys to the .env.local
file.
3. Configuring Twilio and setting up SMS OTP’s
Let’s take a moment to discuss the Twilio OTP method for delivering your authentication code. Don’t worry if you’re new to coding, and it might seem scary; I’ll explain everything in a beginner-friendly manner.
To get started with Twilio, sign up for an account and follow a few straightforward steps. You can find an example of how to set this up here.
You will need to go through the process of verifying a service
to be able to send verification codes.
Once you end up in the console, this is what the Twilio dashboard looks like. Create your free account, and you will have access to your Account SID
and Auth Token
. Please make sure to add them to your .env.local
file.
As part of our free trial, we also need to verify our own phone number and get a free Twilio-generated phone number we can use to receive our texts. The free phone number comes as part of the trial package, so there is no need for you to pay.
Adding a verified phone number & a Twilio Active Number
From the console, navigate to Phone Numbers
> Verified Caller IDs
. Next, in the top right corner, click Add a new Called ID
.
Fill out the box with your area code and phone number.
Next, navigate to Phone Numbers
> Active Numbers
, and create your free phone number as part of the trial.
4. Adding SMS Delivery Functionality
It’s now time to see SuperTokens and Twilio in action. In the backendConfig.js
file, add the below import:
import { TwilioService } from "supertokens-node/recipe/passwordless/smsdelivery";
Now add the code snippet below:
require("dotenv").config();
import SessionNode from "supertokens-node/recipe/session";
import Dashboard from "supertokens-node/recipe/dashboard";
import { appInfo } from "./appInfo";
import PasswordlessNode from "supertokens-node/recipe/passwordless";
import { TwilioService } from "supertokens-node/recipe/passwordless/smsdelivery";
export let backendConfig = () => {
return {
framework: "express",
supertokens: {
// this is the location of the SuperTokens core.
connectionURI:
"https://dev-d8165211046a11ee8b9f3fc3a7d3670f-eu-west-1.aws.supertokens.io:3569",
apiKey: "9JjevRnn9-7FdexV-1OoDiYyHYglv-",
},
appInfo,
// recipeList contains all the modules that you want to
// use from SuperTokens. See the full list here: https://supertokens.com/docs/guides
recipeList: [
PasswordlessNode.init({
flowType: "USER_INPUT_CODE",
contactMethod: "PHONE",
// ############### ADD THE SNIPPET BELOW ##############
smsDelivery: {
service: new TwilioService({
twilioSettings: {
accountSid: process.env.TWILIO_ACCOUNT_SID,
authToken: process.env.TWILIO_AUTH_TOKEN,
from: process.env.TWILIO_TRIAL_PHONE_NUMBER,
},
}),
},
// #####################################################
}),
SessionNode.init(),
Dashboard.init(),
],
isInServerlessEnv: true,
};
};
It’s time to test — navigate to http://localhost:3000/auth
, select your country code, enter your phone number, and wait until the code arrives. Got it? Hurray!!!
5. Adding Authentication API’s to secure URL access
Implementing the backend APIs for authentication is fairly simple. Here are the steps:
- Create an
auth
folder within thepages/api/
directory. - Create a file named
[[…path]].js
 , and insert the following code into it:
import { superTokensNextWrapper } from "supertokens-node/nextjs";
import supertokens from "supertokens-node";
import { middleware } from "supertokens-node/framework/express";
import { backendConfig } from "../../../../config/backendConfig";
supertokens.init(backendConfig());
export default async function superTokens(req, res) {
await superTokensNextWrapper(
async (next) => {
res.setHeader(
"Cache-Control",
"no-cache, no-store, max-age=0, must-revalidate"
);
await middleware()(req, res, next);
},
req,
res
);
if (!res.writableEnded) {
res.status(404).send("Not found");
}
}
The [[…path]].js
file leverages the middleware provided by supertokens-node
, which exposes essential APIs, such as sign-in and sign-up.
6. Protect restricted website routes with session verification
To see this inaction, let’s create a restricted page.Â
- Under the
pages
folder, add a new file calledrestricted.js
. - Inside this file, paste the following code:
import React from "react";
export default function Restricted() {
return (
<div style={{ widows: "100%", padding: "10%" }}>
<h2
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "sans-serif",
}}
>
Restricted File - Only Authenticated Users can access this page.
</h2>
</div>
);
}
- Try accessing
http://localhost:3000/restricted
. You should see the page below.
Currently, it’s accessible even if we have not logged in — so let’s put the restrictions in place. We need to add enforcement code to the restricted.js
file. It will look like this:
import React from "react";
import { SessionAuth } from "supertokens-auth-react/recipe/session";
export default function Restricted() {
return (
<SessionAuth>
<div style={{ widows: "100%", padding: "10%" }}>
<h2
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "sans-serif",
}}
>
Restricted File - You can see this because your are authenticated!
</h2>
</div>
</SessionAuth>
);
}
As you can see, any page you don’t want anyone to access must be wrapped in <SessionAuth>
tags. They will automatically redirect users to the login if they are unauthenticated (currently do not have a session).Â
Before we go ahead and test all of this, we need the option to log users out. Let’s add this quickly.Â
Navigate to the index.js
file and replace the first <p>
under the <main>
element with the following:
<p style={{ cursor: "pointer" }} onClick={() => signOut()}>
Sign Out
</p>
Even though this will give you no visual feedback, it should have worked!
Give it a shot and navigate to http://localhost:3000/restricted to witness all the updates firsthand. Initially, you’ll encounter a login screen. Any attempts to reach the /restricted
page will redirect you back to the login. Proceed by logging in and entering your OTP code for access. Upon successful login, try revisiting the page. You should have unrestricted access by now.
7. Implementing Basic Authorization
Authentication is just confirming a user’s identity  —  are they really the person they claim to be? Once a user is authenticated, it is important to enforce what the authenticated user can do inside the application itself. That’s Authorization.
Every user that successfully authenticates will need to be assigned a role. Access to different parts of the application or a user's actions will be decided based on their designated role. That’s a policy.
The /restricted
page is currently available to everyone who is authenticated. However, we only want users to access the restricted page if they are an Administrator
.
To get started, make an account with Permit here. Permit is a full-stack authorization service that allows us to manage the policies we assign to each role with a no-code UI.Â
There are a few steps we need to go through to set this up:
- Install the Permit SDK
npm install permitio
- Create a Permit instance within your application through our cloud PDP, a dedicated microservice for authorization. Inside the
/pages/api
directory, establish a new subdirectory named/access
and include a file namedauthorize.js
in it. Finally, input the following code into that file:
import { Permit } from "permitio";
const permit = new Permit({
pdp: "https://cloudpdp.api.permit.io",
token: process.env.PERMIT_SDK_TOKEN || "YOUR_PERMIT_SDK_API_KEY_HERE",
});
- Retrieve the Permit API Key and pass it into the Permit instance object. Make sure to add this to yourÂ
.env.local
file.
You can follow this simple Quickstart guide which you can find here if you prefer.Â
Once we have everything set up, we can start to configure our policies to enforce access and make sure only administrators can access the /restricted
page.
1. Setting up a basic policy in Permit’s no-code dashboard
Navigate to the Policy page - you can find it on the left navigation bar.
As we have no roles yet, as soon as we click Manage Roles
we will be prompted to create our first role. Let’s do that!
We only need one role, an administrator, and we can add a short description of the role.
We created a role! Now what? Now we need to add a resource, something we will be performing an action on — and thus testing if we have the permission to do so.
Navigate to the Resources
tab at the top, and let’s create a new resource.
The resource will be the restricted page. Either someone with the role of administrator will be able to access it, or not at all. We named the resource restricted-page
 , and we added just one action; view
. Let’s save it.
Great! We are all good to go to enforce the policy in our code.
2. Enforcing the Permit Policy
We need to add some more logic to our authorize.js
page. Firstly, let’s enforce that only users who are currently in a session can access this page.
As you can notice, if the session exists, we print the userId
. We will be using that to identify our users within Permit.
export default async function isAllowed(req, res) {
await superTokensNextWrapper(
async (next) => {
await verifySession({ sessionRequired: false })(req, res, next);
},
req,
res
);
let session = req.session;
if (session !== undefined) {
// session exists
let userId = session.getUserId();
console.log("USER_ID: ", userId);
} else {
// session doesn't exist
}
}
Now let’s add a function to control the enforcement with permit.check()
. Here we are passing in four parameters to the function. The current user ID from the session
, the action
we are performing, the resource
we are performing the action on, and finally, the tenant
our user is part of.
const checkPermission = async (userId) => {
const access = await permit.check(
userId,
"view",
"restricted-page",
"default"
);
return access;
};
What is a tenant? It’s a silo of resources and users, which in policy terms, means only users within a tenant can act on the resources within the tenant. Tenants are isolated from one another. You can read more about tenants here. The principle is that a user has to be part of a tenant.Â
Let’s create a tenant and add our user using the permit no-code dashboard.
We can call the tenant default
.
Now we need to add our user.Â
Here we passed in an email and, most importantly a key
. The key is the unique id of the user we have taken from the session. If you run the code, you will be able to fetch your current userId
and paste it into the key
field.
Now, let’s add the enforcement login to the current autorize.js
code.
export default async function isAllowed(req, res) {
await superTokensNextWrapper(
async (next) => {
await verifySession({ sessionRequired: false })(req, res, next);
},
req,
res
);
let session = req.session;
if (session !== undefined) {
// session exists
let userId = session.getUserId();
console.log("USER_ID: ", userId);
// ############### NEW CODE #################
// Calling the previously defined function
const hasAccess = await checkPermission(userId);
console.log(hasAccess);
// sending back a status based on the access
if (hasAccess) {
res.status(200).send({ id: userId, allowed: hasAccess });
} else {
res.status(404).send({ id: userId, allowed: hasAccess });
return;
}
} else {
// session doesn't exist
console.log("Session does not exist");
res.status(401).send("Unauthorized");
}
}
Finally, let’s edit our /restricted
page. This time, we need to call the /api/access/authorize
endpoint and based on the result, restrict the access.
Change the restricted.js
code to this:
import React, { useEffect } from "react";
import useSWR from "swr";
export default function Restricted() {
const fetcher = (url) => fetch(url).then((res) => res.json());
const { data, error } = useSWR("/api/access/authorize", fetcher);
if (error) return <div>failed to load</div>;
if (!data) {
return <div>Loading...</div>;
} else {
if (data.allowed) {
return <div>Welcome user: {data.id}</div>;
} else {
return <div>User is not an administrator</div>;
}
}
}
Yay! We are done! Now — let’s test the app!
If we try to access the restricted page while we are not logged in, we get a failed to load
message.
Now let's login.
What will we see now when we try to access the restricted page?
Oh no! It looks scary, but it’s not a problem. As Permit’s UI is so easy to use, we can very quickly change the permission for that user, without adding any extra code.
Let’s go back to Permit’s Policy Editor. While here, we just need to check the view
action for the restricted-page
resource, and save the changes!
Let’s go back to the restricted page and see the outcome!
Amazing! Congratulations! You have successfully built a passwordless login with SuperTokens and Twilio, and even went as far as implementing basic authorization.Â
If you want to learn more about Permit — check out Permit.io — otherwise, you can download this whole demo project here.
Written by
Filip Grebowski
Developer Advocate at Permit.io, Software Engineer, and YouTube Creator