Access Control List (ACL) is a security layer to restrict access to resources based on the user's or client's assigned role. While it's being widely used to protect APIs, it can be convenient to also hide some features on the interface from unauthorized users. Instead of duplicating the permissions definition and logic, we'll see how we can seamlessly combine and share them across the application front-end and back-end with Next.js.
Note that implementing ACL on the front-end only is not enough of a security. An advised user could inject the permissions and reveal any protected feature you're trying to hide from them. That's why it's important to protect both ends of the application.
Before we start
In this article, I'll use the AccessControl package to implement ACL because I like its structure, performances, and simple usage, but the example can be replicated with other ACL packages as well.
I also heavily rely on next-connect to chain middlewares. Again, it's not mandatory, but it makes the API handlers less verbose and therefore shorten the examples.
The folder structure I've set is completely arbitrary apart from pages
which is the entry point in Next.js. I share it there in order to clarify the examples:
+-- public/ // Static assets
+-- src/ // Whole application source code
| +-- app/ // Front-end utilities
| | +-- components/ // Base resuable components
| | +-- features/ // Custom hooks, helpers, contexts
| | +-- layouts/ // App specific components
| +-- pages/ // Entry point for Pages
| | +-- api/ // Entry point for API routes
| +-- server/ // Back-end utilities
| | +-- helpers/ // Helper functions
| | +-- middlewares/ // API middleware functions
| | +-- services/ // Third-party services instances
1. Defining the permissions
AccessControl stores permissions into memory. We'll define permissions in a module so they're centralized, and loaded any time we require the module for the first time after the process starts.
// src/server/services/accesscontrol.js
// ----------------------------------------
import AccessControl from 'accesscontrol';
// unique back-end instance of AccessControl
export const ac = new AccessControl();
// owners can manage all users
ac.grant('owner').resource('users').readAny().createAny().updateAny().deleteAny();
// guests can manage only their own profile
ac.grant('guest').resource('users').readOwn().updateOwn();
// we lock ACL to avoid updates out of this file
ac.lock();
// src/server/services/index.js
// ----------------------------------------
export { ac } from './accesscontrol';
2. Protecting API routes
The basic way to check a permission is running:
const permission = ac.can('guest').readOwn('users');
console.log(permission.granted); // -> true
// or
const permission = ac.permission({
role: 'guest',
resource: 'users',
action: 'read:own',
)};
console.log(permission.granted); // -> true
But having to pass the role each time we ask for a permission is a bit verbose and unnecessary. It's up to us to implement a middleware the way we want it, and I usually store the user's role in session. Let's create a checkAccess
middleware that will read the role from the session so there's no need to specify it at each call.
// src/server/middlewares/checkAccess.js
// ----------------------------------------
import { ac } from 'server/services'; // load ACL
export const checkAccess = (resource, action, possession) => (req, res, next) => {
let permission;
try {
permission = ac.permission({
role: req.session?.user?.role, // we'll see later about that
resource,
action,
possession,
});
} catch { // `ac.permission` throws if role is not a string
permission = { granted: false };
}
// return 403 if access is denied
if (!permission.granted) {
return res.status(403).json({
ok: false,
message: 'You are not authorized to access this resource',
});
}
return next();
};
// src/server/middlewares/userAuth.js
// ----------------------------------------
import { getSession } from 'next-auth/client';
// check if user is authenticated
export const userAuth = async (req, res, next) => {
// store session into request to pass it to following middlewares
req.session = await getSession({ req });
if (!req.session) {
return res.status(403).send({
ok: false,
message: `User authentication required.`,
});
}
return next();
};
// src/server/middlewares/index.js
// ----------------------------------------
export { checkAccess } from './checkAccess';
export { userAuth } from './userAuth';
Then we can simply chain it before our final API route handler to protect its access:
// src/page/api/users/[id].js
// ----------------------------------------
import nc from 'next-connect';
import { checkAccess, userAuth } from 'server/middlewares';
const handler = nc();
.use(userAuth) // injects session into req.session
.use(checkAccess('users', 'read:own'))
.use(checkAccess('profile', 'read:own')) // you can chain multiple checks
.get((req, res) => {
// get user profile from DB
return res.send({
ok: true,
data: user,
});
});
export default handler;
What about specific permissions per method?
// src/page/api/users/[id].js
// ----------------------------------------
import nc from 'next-connect';
import { checkAccess, userAuth } from 'server/middlewares';
const handler = nc();
.use(userAuth) // injects session into req.session
.get(checkAccess('users', 'read:own'), (req, res) => {
// get user profile from DB
return res.send({
ok: true,
data: user,
});
})
.put(checkAccess('users', 'update:own'), (req, res) => {
// update user profile in DB
return res.send({
ok: true,
data: user,
});
})
.delete(checkAccess('users', 'delete:any'), (req, res) => {
// delete user profile from DB
return res.send({
ok: true,
data: {},
});
});
export default handler;
3. Adding user role to the session
NextAuth makes it a breeze to handle user authentication with both email and social accounts. Though we'll need to extend it a little bit to add a role
column into the users
database table it creates, and then inject the role into the session. Following their documentation, we end up doing:
// src/page/api/[...nextauth].js
// ----------------------------------------
import NextAuth from 'next-auth';
import Adapters from 'next-auth/adapters';
import Providers from 'next-auth/providers';
const options = {
site: process.env.NEXTAUTH_URL,
database: process.env.DATABASE_URL,
providers: [/* ... */],
// alter user schema to add a `role` column
adapter: Adapters.TypeORM.Adapter(
process.env.DATABASE_URL,
{
models: {
User: {
model: Adapters.TypeORM.Models.User.model,
schema: {
...Adapters.TypeORM.Models.User.schema,
columns: {
...Adapters.TypeORM.Models.User.schema.columns,
role: {
type: 'varchar',
nullable: true,
},
}
}
},
},
}
),
// assign default role on user creation
events: {
createUser: async (user) => {
// use your database package to safely perform the following query:
await db.run(
'UPDATE users SET role="guest" WHERE id=${id}',
{ id: user.id }
);
},
},
// enrich session data
callbacks: {
session: async (session, user) => {
session.user.role = user.role; // inject role into session
return Promise.resolve(session);
},
},
};
export default (req, res) => NextAuth(req, res, options);
4. Sharing permissions with the front-end
To check permissions on the front-end, we could technically just require src/server/services/accesscontrol.js
and perform the same checks using ac.can('guest').readOwn('users').granted
for example. But as we're in the browser and already using React, better spare as much memory as we can and not load the full list of permissions, that can be very long in large applications.
That's why the strategy here is to pass only the permissions tied to the user's role, and for this, we'll again rely on the session object. Let's update our session callback in NextAuth options:
// src/page/api/[...nextauth].js
// ----------------------------------------
// ...
import { ac } from 'server/services';
// ...
const options = {
// ...
// enrich session data
callbacks: {
session: async (session, user) => {
session.user.role = user.role;
// get all permissions
const grants = ac.getGrants();
// expose only the current role permissions
session.user.permissions = user.role in grants ?
{ [user.role]: grants[user.role] } :
{};
return Promise.resolve(session);
},
},
};
// ...
We'll then create an independent and volatile instance of AccessControl in the front-end, and store the permissions into a React context to check access from any component.
4. Storing permissions into a context
Again here, to avoid passing the role as argument each time to AccessControl, we'll expose a nice wrapper from the React context we'll create for accessing user's data:
// src/app/features/UserContext.jsx
// ----------------------------------------
import AccessControl from 'accesscontrol';
import { useSession } from 'next-auth/client';
import React, { createContext, useCallback, useContext, useEffect } from 'react';
// front-end isolated instance of AccessControl
const ac = new AccessControl();
// create and export User context
export const UserContext = createContext({
access: () => ({ granted: false }),
isLoading: false,
user: {},
});
// export hook context wrapper
export const useUserContext = () => useContext(UserContext);
// export pre-configured provider
export const UserProvider = (props) => {
const [session, isLoading] = useSession();
// update permissions from session data
const permissions = session?.user?.permissions || {};
useEffect(
() => {
ac.setGrants(permissions);
return () => {
ac.reset(); // reset when permissions change
};
},
[permissions]
);
// expose access checking wrapper method
// automatically fill user role on permission check
const role = session?.user?.role || 'guest';
const access = useCallback(
(resource, action, possession) => {
try {
return ac.permission({
role,
resource,
action,
possession,
});
} catch { // if role is not a string
return { granted: false };
}
},
[role]
);
// Return context values
// ----------------------------------------
const context = {
access,
isLoading,
user: session?.user,
};
return (
<UserContext.Provider value={context}>
{props.children}
</UserContext.Provider>
);
};
// src/app/features/index.js
// ----------------------------------------
export { UserContext, useUserContext } from './UserContext';
Then we only need to wrap our application with the User context inside src/pages/_app.js
:
// src/pages/_app.js
// ----------------------------------------
import React from 'react';
import { UserProvider } from 'app/features';
const App = ({ Component, pageProps }) => (
<UserProvider>
<Component {...pageProps} />
</UserProvider>
);
export default App;
5. Hiding features from unauthorized users
The setup is now complete, we have protected our API routes and the users, even if they hack their way to access the features from the interface, they won't be able to perform any action on the API as long as your permissions are defined accordingly.
So, hiding content on the interface becomes a matter of seconds using our User context, for example to filter the list of accessible links in the main navigation:
// src/app/layouts/MainNav.jsx
// ----------------------------------------
import Link from 'next/link';
import { useUserContext } from 'app/features';
const MainNav = (props) => {
const router = useRouter();
const { access } = useUserContext();
return (
<ul>
<li>
<Link href="/">
<a>Dashboard</a>
</Link>
</li>
{access('users', 'read:any').granted && (
<li>
<Link href="/users">
<a>Manage users</a>
</Link>
</li>
)}
{access('users', 'read:own').granted && (
<li>
<Link href="/profile">
<a>Profile</a>
</Link>
</li>
)}
</ul>
);
};
The code examples may seem quite long, but we've basically just wrapped some AccessControl features to provide a better developer experience within our stack, ending up with a unified and performant solution to protect both front-end and back-end features using a single ACL declaration. Once the setup complete, the usage is a matter of adding a single line of code here and there.
If you're not using any of the packages here, it's still quite easy to replicate the strategy with vanilla Next.js and whichever session manager you use. I personally also assign a role to any API client (the front-end is a client too) based on the Bearer Token they're using (into req.role
directly, overriden when there's a user session with role: req.session?.user?.role || req.role
in checkAccess
middleware). That way I can open some API routes for external user-less usage and limit their scope with the same ACL layer.
Top comments (4)
Great article! This was very educational. You're covering stuff that's really important for any big application but nobody talks about it. Any plans on covering some of these stuff in TypeScript as well?
First of all, thanks for your comment! Sorry I have no plans to cover TypeScript (I don't like TS but that's not the reason). The actual purpose of not putting Typescript examples here is to focus on logic and implementation. Translating it to TypeScript will serve no purpose but faster copy-paste for these specific readers, which isn't recommended anyway!
Fantastic content, well done! Very important aspect of Full-stack Next.js app that doesn't get mentioned in all these basic tutorials around.
Excellent writeup
Hi is there any links to the demo source of this example.
Trying to learn the framework.