Implementing Authentication and Authorization in Next.js
- Share:
Next.js is a popular framework for quickly and efficiently building server-side rendered web applications. However, it does not come with built-in authentication or authorization, leaving developers to implement these crucial features themselves.
Authentication and authorization are fundamental in application security, serving two distinct purposes:
Authentication answers the question, "Who are you?" It verifies a user's identity through credentials like passwords, social logins, or biometrics. In a Next.js application, authentication ensures that only legitimate users can access the application.
Authorization answers the question, "What are you allowed to do?". After users are authenticated, authorization determines what resources they can access and what actions they can perform. This creates a personalized experience where users only see and interact with what's relevant to their role.
In this article, I'll show you how to integrate two specialized services—Logto for authentication and Permit.io for authorization—into your Next.js application.
These tools work well together and provide a clean separation of concerns – Logto handles user identity and login flows, while Permit.io manages permissions and access control.
By the end of this article, you'll have a Next.js app with role-based permissions (RBAC), where users are rendered different UIs based on their roles.
What We'll Build
Our app will consist of a dashboard with three user roles:
- Admin: Can
view
,edit
, anddelete
resources - Editor: Can
view
andedit
resources - Viewer: Can only
view
the resources
We'll implement role-based access control to show different UI elements and functionality based on the user's role.
Prerequisites and Tech Stack
To follow along, you'll need:
- Node.js and npm installed
- A Logto account (free to sign up)
- A Permit.io account (also free to start)
We'll use:
- Next.js
- Logto for authentication
- Permit.io for authorization
- Tailwind CSS for styling
Planning Our Authorization
Before diving into the code, let's clearly map out our Role-Based Access Control (RBAC) structure. This planning step is important for any successful authorization implementation.
Without proper planning, you might end up with inconsistent permission checks scattered throughout your components, making your application difficult to maintain and potentially introducing security vulnerabilities.
RBAC provides a structured approach to permissions that scales well as your application grows. By grouping permissions into roles and assigning users to those roles, you create a more manageable system than individually assigning permissions to each user.
Resources and Actions
Resources represent the entities in your application to which you want to control access.
Actions are the operations that can be performed on those resources.
In a typical application, resources could be anything from "Posts" and "Comments" to "Orders" and "Invoices." Actions could include view
, create
, edit
, and delete
.
For example, if you're building a content management system, your resources might include "Articles," "Users," and "Comments.”
In our case, we'll keep it simple and focus on a single resource: "Reports". This resource will have three actions: view
, edit
, and delete
.
Roles and Permissions
Let's define who can do what in our system:
Role | Reports |
---|---|
admin | view, edit, delete |
editor | view, edit |
viewer | view |
This permission structure makes sense for our application, where:
- Admins need complete control over the reports
- Editors can create and modify reports, but not delete them
- Viewers can only see the reports
Let’s start implementing!
Setting Up the Next.js Project
Let's start by creating a new Next.js project:
npx create-next-app permitio-logto-demo
cd permitio-logto-demo
Next, let's install the packages we'll need:
npm install @logto/next permitio swr
Implementing Authentication with Logto
Setting Up Logto
Logto simplifies the authentication process with features like social logins, multi-factor authentication, and session management - meaning you don’t have to build any of these from scratch.
For Next.js applications specifically, Logto provides SDK support that integrates with the framework's routing system and server-side rendering capabilities.
To set up Logto for our Next.js application, follow these steps:
- Sign up for a free account at logto.io
- Go to the Logto console to create a new application
- Click the "Applications" tab and select a framework, in our case, "Next.js (Pages Router)"
- Give it a name like "Next.js Demo."For the Redirect URI, use:
http://localhost:3000/api/logto/sign-in-callback
- Add a post sign-out redirect URI:
http://localhost:3000/
These redirect URIs are crucial for the OAuth flow that Logto uses behind the scenes. After successful authentication, users will be redirected to the sign-in callback URL, and then to the post-sign-out URL after logging out.
Note down your App ID, App Secret, Endpoint, and Cookie Secret - we'll need these for configuration.
Creating a new application in Logto
Configuring Logto in Next.js
Create a .env.local
file in your project root with the following keys:
LOGTO_ENDPOINT=your-logto-endpoint
LOGTO_APP_ID=your-app-id
LOGTO_APP_SECRET=your-app-secret
LOGTO_COOKIE_SECRET=your-cookie-secret
NODE_ENV="development"
Now, let's set up a client for Logto. Create a new file called libraries/logto.js
:
import LogtoClient, { UserScope } from "@logto/next";
export const logtoClient = new LogtoClient({
scopes: [UserScope.Email],
endpoint: process.env.LOGTO_ENDPOINT,
appId: process.env.LOGTO_APP_ID,
appSecret: process.env.LOGTO_APP_SECRET,
baseUrl: "<http://localhost:3000>",
cookieSecret: process.env.LOGTO_COOKIE_SECRET,
cookieSecure: process.env.NODE_ENV === "production",
});
Next, we need to create the API routes for Logto. Create another file with the following code in pages/api/logto/[action].js
:
import { logtoClient } from "../../../libraries/logto";
export default logtoClient.handleAuthRoutes();
That’s all you need!
With our authentication set up with Logto, we now need to handle authorization.
Setting Up Authorization Policies in Permit.io
While you could hard-code authorization logic directly into your Next.js code, this approach usually isn’t scalable as your application grows. Changes to permission rules would require code modifications and redeployments, and there's no centralized way to manage or audit who has access to what.
Permit.io solves these challenges by providing a dedicated authorization service with a flexible policy engine. By separating authorization from your application code, you gain:
- Centralized policy management: Update permissions without changing code
- Audit logs: Track all permission checks for security compliance
- Fine-grained control: Implement complex authorization rules beyond simple role checks
Let's set up Permit.io for our Next.js application:
- Sign up for a free account at app.permit.io
- Navigate to the "Projects" tab in the console
- Click on "New Project" to create a new project
- Give it a name like "Next.js Demo" and click "Create".
- Obtain the API key for the development environment and include it in your
.env.local
file:
PERMIT_API_KEY=your-permit-api-keyObtaining a project api key in Permit.io
Creating Resources
- Navigate to "Policy" → "Resources" in the left sidebar
- Click the "Create Resource" button
- Add a resource called "Reports"ת and click "Create".
- Add the following actions to the resource:
view
,edit
, anddelete
- Click "Save" to save the resource
Creating a new resource in Permit.io
Defining Roles
- Navigate to "Policy" → "Roles"
- Click the "Add Role" button
- Add each of these roles one by one:
- admin: Users with complete access to the system
- editor: Users who can only edit content
- viewer: Users with read-only access
- By default, Permit.io will create these three roles for you.
Creating roles in Permit.io
Configuring Permissions
- Navigate to "Policy" → "Policy Editor"
- Set the permissions according to our planned structure:
- Admin: Enable all actions in the Reports resource
- Editor: Enable View Reports and Edit Reports
- Viewer: Enable only View Reports
Creating policies in Permit.io
This configuration creates the foundation of your authorization system in Permit.io. Remember that you can always adjust these settings as your application evolves without changing your code.
Integrating Permit.io into Next.js
Now, let's set up Permit.io in our Next.js application to enforce these permissions.
Setting Up Permit.io Client
First, let's create a file for our Permit.io configuration.
Create a new file called libraries/permit.js
:
const { Permit } = require("permitio");
// Initialize the Permit.io client
const permit = new Permit({
pdp: "<https://cloudpdp.api.permit.io>",
token: process.env.PERMIT_API_KEY,
});
// Sync a user with Permit.io
export const syncUserToPermit = async (
userId,
email,
firstName,
lastName,
role
) => {
// First, sync the user
await permit.api.syncUser({
key: userId,
email: email || undefined,
first_name: firstName || undefined,
last_name: lastName || undefined,
});
// Then assign a role to the user (in the default tenant)
if (role) {
await permit.api.assignRole({
user: userId,
role: role,
tenant: "default",
});
}
return true;
};
// Check if a user has permission to perform an action on a resource
export const checkPermission = async (userId, action, resource) => {
return await permit.check(userId, action, resource);
};
This file provides two key functions:
syncUserToPermit()
: Syncs a user to Permit.io and assigns them a rolecheckPermission()
: Checks if a user has permission to perform an action
Creating an API Endpoint for Permission Checks
Create an API endpoint to check permissions. Create a new file called pages/api/check-permission.js
:
import { checkPermission } from "../../libraries/permit";
export default async function handler(req, res) {
const { userId, action, resource } = req.query;
if (!userId || !action || !resource) {
return res.status(400).json({ error: "Missing required parameters" });
}
try {
const isPermitted = await checkPermission(userId, action, resource);
return res.status(200).json({ isPermitted });
} catch (error) {
console.error("Error checking permission:", error);
return res.status(500).json({ error: "Failed to check permission" });
}
}
This endpoint will allow us to check if a user has permission to perform a specific action on a resource. It accepts userId
, action
, and resource
as query parameters.
Syncing Logto Users with Permit.io
One of the challenges in implementing a complete auth system is keeping user data synchronized between authentication and authorization services.
In our Next.js application, we need to ensure that when users register through Logto, their information is also available in Permit.io for permission checks.
Rather than manually synchronizing users or building a complex background process, we can leverage Logto's webhook system to trigger user synchronization when registration events occur automatically.
Let's create a new file called pages/api/webooks/logto.js
to automatically sync new users to Permit.io upon signup.
import { syncUserToPermit } from "../../../libraries/permit";
export default async function handler(req, res) {
const { event, user } = req.body;
if (event === "PostRegister") {
try {
let role = "viewer"; // Default role
if (user.primaryEmail) {
if (user.primaryEmail.includes("admin")) {
role = "admin";
} else if (user.primaryEmail.includes("editor")) {
role = "editor";
}
}
// Sync user with Permit.io
await syncUserToPermit(
user.id,
user.primaryEmail,
user.name,
undefined,
role
);
return res.status(200).json({ success: true });
} catch (error) {
console.error("Error syncing user:", error);
return res.status(500).json({ error: "Failed to sync user" });
}
}
return res.status(200).json({ message: "Event ignored" });
}
This Next.js API route acts as a webhook endpoint that Logto will call whenever a new user registers. We're using a simple email-based role assignment strategy here.
In a real-world app, you might use more sophisticated rules based on user attributes, organization membership, or external data sources.
Once this webhook is configured in the Logto console, the synchronization happens automatically, with no additional code required in your main application flow. This keeps your Next.js components focused on rendering UI rather than managing user synchronization logic.
Configuring the Webhook in Logto
We need to configure the webhook in Logto to call our endpoint when a new user registers. In your Logto console, go to the "Webhooks" section and add a new webhook with the URL: http://your-server-url/api/webhooks/logto
. Make sure to select the PostRegister
event.
This webhook will be triggered whenever a new user registers and automatically syncs the user with Permit.io.
Registering a webhook in Logto
Note that you have to host this webhook endpoint somewhere accessible to Logto. You can use tools like ngrok to expose your local server to the internet for local development. Once you have a public URL, you can use it to configure the webhook in your Logto console.
Creating a Custom Hook for Client-Side Permission Checks
Authorization decisions are often used in React components to determine what UI elements to render. While we could make permission checks directly in each component, this would lead to duplicated code and potential inconsistencies.
React hooks provide a perfect solution for this problem. By creating a custom usePermissions
hook, we can:
- Centralize our permission-checking logic in one place
- Provide a clean, reusable API for components to consume
- Handle loading states and errors consistently throughout the application
- Cache permission results to avoid unnecessary API calls
Let's create this hook in a new file called hooks/usePermissions.js
to handle our permission checks:
import { useState, useEffect } from "react";
export function usePermissions(userId) {
const [permissions, setPermissions] = useState({
"view:Reports": false,
"edit:Reports": false,
"delete:Reports": false,
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!userId) {
setLoading(false);
return;
}
const checkPermission = async url => {
try {
const response = await fetch(url);
if (!response.ok) {
console.warn(`Permission check failed: ${response.status}`);
return { allowed: false };
}
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
console.warn("Invalid response format");
return { allowed: false };
}
return response.json();
} catch (err) {
console.error("Permission check error:", err);
return { allowed: false };
}
};
// Function to check all permissions we need
const checkPermissions = async () => {
try {
setError(null);
const results = await Promise.all([
checkPermission(
`/api/check-permission?userId=${userId}&action=view&resource=Reports`
),
checkPermission(
`/api/check-permission?userId=${userId}&action=edit&resource=Reports`
),
checkPermission(
`/api/check-permission?userId=${userId}&action=delete&resource=Reports`
),
]);
setPermissions({
"view:Reports": results[0].isPermitted,
"edit:Reports": results[1].isPermitted,
"delete:Reports": results[2].isPermitted,
});
} catch (error) {
console.error("Error checking permissions:", error);
setError(error.message);
setPermissions({
"view:Reports": false,
"edit:Reports": false,
"delete:Reports": false,
});
} finally {
setLoading(false);
}
};
checkPermissions();
}, [userId]);
// Helper function to easily check permissions
const can = (action, resource) => {
return permissions[`${action}:${resource}`] || false;
};
return { permissions, loading, error, can };
}
For this demo, we're checking the permissions for the "Reports" resource. It returns an object containing the permissions, a loading state, an error if the request failed, and a function can
that takes two arguments: the action (like view
, edit
, or delete
) and the resource (in our case, Reports
). The can
function returns a boolean indicating whether the user has that permission.
This hook will be used in our dashboard component to conditionally render UI elements based on the user's permissions. You would likely have multiple resources and actions in a real-world application, so you should create a better structure to handle that.
Building the Dashboard with Role-Based UI
With a proper authorization system, we can create dynamic UIs that adapt to user permissions. Instead of creating separate pages for different user roles, we can use conditional rendering to show or hide UI elements based on what the user can do.
Update our pages/index.jsx
file to create a dashboard that uses the usePermissions
hook to render UI elements based on the user's permissions conditionally:
import { useEffect } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { usePermissions } from "../hooks/usePermissions";
export default function Dashboard() {
const router = useRouter();
const fetcher = url => fetch(url).then(r => r.json());
const { data, error } = useSWR("/api/logto/user", fetcher);
// Get permissions for the current user
const { can, loading: permissionsLoading } = usePermissions(
data?.claims?.sub
);
// Redirect to login if not authenticated
useEffect(() => {
if (data && !data.isAuthenticated && !error) {
router.push("/login");
}
}, [data, error, router]);
const handleSignOut = () => {
window.location.assign("/api/logto/sign-out");
};
if (error) return <div>Error loading user data</div>;
if (!data || permissionsLoading) return <div>Loading...</div>;
if (!data?.isAuthenticated) return null;
return (
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow px-6 py-4">
<div className="flex justify-between">
<h1 className="text-xl font-bold">Reports Dashboard</h1>
<div className="flex items-center space-x-4">
<span>{data.claims?.email || data.claims?.sub}</span>
<button
onClick={handleSignOut}
className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300">
Sign out
</button>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto py-6 px-4">
<h2 className="text-2xl font-bold mb-6">Your Reports</h2>
{can("view", "Reports") ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Monthly Sales Report</h3>
<div className="flex space-x-2">
{can("edit", "Reports") && (
<button className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600">
Edit
</button>
)}
{can("delete", "Reports") && (
<button className="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600">
Delete
</button>
)}
</div>
</div>
<p className="text-gray-600">
This report shows the monthly sales data for your organization.
</p>
</div>
) : (
<div className="text-center p-6 bg-gray-100 rounded-lg">
You don't have permission to view reports.
</div>
)}
</main>
</div>
);
}
Creating a Login Page
Finally, let's create a login page. Create a new file called pages/login.jsx
:
import { useEffect } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
export default function Login() {
const router = useRouter();
const fetcher = url => fetch(url).then(r => r.json());
const { data, error } = useSWR("/api/logto/user", fetcher);
useEffect(() => {
if (data?.isAuthenticated) {
router.push("/");
}
}, [data, router]);
const handleSignIn = () => {
window.location.assign("/api/logto/sign-in");
};
if (error) return <div>Error loading user data</div>;
if (!data) return <div>Loading...</div>;
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="max-w-md w-full space-y-8 p-10 bg-white rounded-lg shadow-md">
<div className="text-center">
<h1 className="text-2xl font-bold">Welcome to Reports Dashboard</h1>
<p className="mt-2 text-gray-600">Please sign in to continue</p>
</div>
<button
onClick={handleSignIn}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none">
Sign in with Logto
</button>
</div>
</div>
);
}
Running the Application
Now that everything is set up, let's run the application and see how our authorization system works in practice:
npm run dev
Open your browser and navigate to http://localhost:3000/login
. You'll see the login page where you can either log in with an existing account or create a new one.
Testing Different User Roles
To test our permission system properly, you should create three different accounts:
- Admin account: Use an email containing "admin" (e.g., admin@example.com)
- Editor account: Use an email containing "editor" (e.g., editor@example.com)
- Viewer account: Use any other email (e.g., viewer@example.com)
Upon signing in, you'll be redirected to the dashboard, where you'll see the reports section. The UI will dynamically adapt based on your permissions:
An ordinary user’s view of the reports dashboard shows that no action buttons are enabled, demonstrating role-based component rendering for users with full permissions.
An editor’s view of the reports dashboard shows only one action button (Edit) enabled, demonstrating role-based component rendering for users with full permissions.
An admin view of the reports dashboard shows all action buttons (Edit and Delete) enabled, demonstrating role-based component rendering for users with full permissions.
Observing Authorization in Action
Let's look at how our permission system affects what each user can see:
- Admin View: Admins see the full dashboard with complete functionality - they can view, edit, and delete reports.
- Editor View: Editors can see the reports and have the ability to edit them, but the delete button is hidden since they don't have deletion permissions.
- Viewer View: Viewers can only see the reports without any action buttons.
Verifying User Roles in Permit.io
In our Permit.io console, we can verify that users are properly synced with their assigned roles:
Logto Users Synced to Permit.io with Role Assignments
The Permit.io dashboard provides complete visibility into your authorization system. It allows you to see all registered users, their role assignments, and even audit logs of permission checks.
Audit logs provide a way to track who did what on your system. You can see what resources they tried to access, at what time, and if they were granted access. This makes it easy to troubleshoot any permission issues and adjust your authorization rules as needed.
What's particularly powerful about this setup is that you can change a user's permissions in the Permit.io dashboard, and the changes will immediately affect what they can access in your application - no code changes or deployments required!
Conclusion
In this article, we've built a Next.js application that uses Logto for authentication and Permit.io for authorization. This combination provides a powerful, flexible solution for implementing role-based access controls without building everything from scratch.
The key benefits of this approach:
- Separation of concerns: Logto handles all the identity management, while Permit.io focuses on permission management.
- Flexibility: You can easily update your access control policies in Permit.io without changing the code.
- Scalability: Logto and Permit.io are designed to scale as your application grows.
This approach gives you a solid foundation for building secure applications with fine-grained access controls, allowing you to focus on building features instead of reinventing authentication and authorization.
Want to learn more about Authorization? Join our Slack community, where there are hundreds of devs building and implementing authorization.
Written by
Daniel Bass
Application authorization enthusiast with years of experience as a customer engineer, technical writing, and open-source community advocacy. Comunity Manager, Dev. Convention Extrovert and Meme Enthusiast.