Add role-based authorization to an Express API with JWT (Node.js)
Short answer: To add role-based authorization to an Express API that uses JWT, verify the token in middleware and call permit.check() with the token's user, an action, and a resource. Permit.io evaluates the check against roles you edit outside your code. Use it when roles change without a redeploy. Don't use it for a few fixed roles in one process; an in-process library such as CASL fits that case.
| Fact | Value |
|---|---|
| Question this page answers | How do I let only admins delete resources and members edit only their own, in an Express API with JWT auth? |
| Models used | RBAC for top-level roles and ReBAC for roles on a single post. ABAC is also supported with a container PDP. |
| PDP | A container PDP at http://localhost:7766 in this tutorial, or the Cloud PDP. See PDP overview. |
| SDK | permitio on npm. The code in this tutorial is written against version 2.7.6. |
| Policy changes | You change roles and permissions in the Permit dashboard. The Express code and the deployment stay the same. |
| Free tier | Community plan, labeled "Free Forever" on the Permit.io pricing page: MAU 1000, Tenants 20, Authorization Queries No Limit, Environments 3, PDP Instances No Limit. |
| Audit | Each check appears on the Audit Log screen of the Permit dashboard. |
| Open source | The Edge PDP (permitio/PDP), OPAL, cedar-agent, the Permit SDKs, and the Permit CLI are open source. See Open-source fallback. |
Build an Express.js API for a blogging platform that registers users in Permit.io and allows only users with the Author role to create posts. This tutorial is for Node.js backend developers who want to enforce Permit.io policies from Express route handlers.
When you finish, your Express.js app has two endpoints:
| Endpoint | What it does |
|---|---|
POST /register | Syncs a user to Permit.io and assigns the user the Reader role in the default tenant |
POST /posts | Calls permit.check() and returns 403 unless the user has permission to create a Post |
Prerequisites
- A Permit.io account. See Create a Permit.io account.
- Node.js and npm, to run the Express.js app and install the Permit CLI.
- Docker, to run the policy decision point (PDP) container.
- An Express.js project, or an empty directory where you create one. See the Express installation guide.
1. Configure the policy in Permit
Create the blogging platform policy with the Permit CLI. If your environment already has a policy with a Post resource and an Author role that can create posts, skip to 2. Get your API key.
Install the Permit CLI
The Permit CLI creates policies and runs the PDP from your terminal. Install the CLI with npm:
npm install -g @permitio/cli
Run permit to confirm that the CLI is installed.
Sign in with the Permit CLI
Authenticate the CLI with your Permit.io account:
permit login
The command opens a browser window where you sign in. After you sign in, the CLI uses your default environment. To use a different environment, run permit env select and choose the environment.
Apply the blogging platform template
Permit CLI templates create a policy with predefined resources, roles, and rules. To see the available templates, run permit env template list. The template source files are in the Permit CLI repository.

Apply the blogging-platform template to your environment:
permit env template apply --template blogging-platform
The CLI prints a success message when the template is applied.
Review the policy in the Policy Editor
In the Permit dashboard, select your project and open the Policy screen.

The blogging-platform template creates:
| Policy element | What the template defines |
|---|---|
| Resources | Post (with a premium boolean attribute) and Comment, each with create, read, update, and delete actions |
| Roles | Admin (all actions), Author (create and read posts, read comments), Reader (create and read comments), and Premium Reader (read posts and comments) |
| Relationship | A Post is the parent of its Comment instances. An Author of a post instance becomes a Moderator of the comments on that post. This rule is relationship-based access control (ReBAC). |
| Resource set | Free Post contains posts where premium is false. Readers can read free posts. This rule is attribute-based access control (ABAC). |
This tutorial uses one rule from the policy: the Author role can create a Post, and the Reader role cannot. To change which role can perform an action, check or clear the box in the Policy Editor.
2. Get your API key
Your Express.js app and the PDP authenticate with Permit.io with your environment API key. Copy the API key of the environment where you applied the template. See Get your API key.
Anyone with the environment API key can change that environment's policy through the Permit API. Load the key from an environment variable, and don't commit it.
3. Run the PDP
The PDP evaluates each permit.check() call against your policy. Start a PDP container with the Permit CLI:
permit pdp run
The command starts the PDP in Docker and prints the container ID and name. The PDP listens on port 7766, so your app connects to it at http://localhost:7766.

The Free Post resource set is an ABAC rule, and the Cloud PDP doesn't evaluate ABAC rules, so run the container PDP for this policy. To run the container with docker run instead, or to check that the PDP is healthy, see Run the PDP.
4. Build the Express.js app
Install the Node.js SDK
In your Express.js project directory, install the Permit Node.js SDK:
npm install permitio
The code in this tutorial also uses express and body-parser. Install those packages with npm if your project doesn't have them. For all SDK options, see the Node.js SDK quickstart.
Initialize the Permit client in server.js
Create a file named server.js with the following code:
// server.js
const express = require('express');
const bodyParser = require('body-parser');
const { Permit } = require('permitio');
const app = express();
const port = 8000;
app.use(bodyParser.json());
const permit = new Permit({
token: process.env.PERMIT_API_KEY,
pdp: process.env.PDP_URL,
});
The code creates an Express.js app on port 8000, parses JSON request bodies with body-parser, and creates a Permit client. The client reads two environment variables:
| Variable | Value |
|---|---|
PERMIT_API_KEY | Your environment API key from 2. Get your API key |
PDP_URL | The PDP address from 3. Run the PDP: http://localhost:7766 |
Add the /register endpoint
Add the following code to server.js. The POST /register handler syncs the user to Permit.io with permit.api.users.sync(), then assigns the user the Reader role in the default tenant.
app.get('/', (req, res) => {
res.json({ message: 'Hello, Express with Permit!' });
});
// Register a new user
app.post('/register', async (req, res) => {
const { email, first_name, last_name } = req.body;
if (!email || !first_name || !last_name) {
return res.status(400).json({ error: 'Missing required fields' });
}
try {
// Sync user with Permit
const user = await permit.api.users.sync({
key: email,
email,
first_name,
last_name,
});
// Assign role as part of registration
const assignedRole = {
user: email,
role: 'Reader',
tenant: 'default'
};
const response = await permit.api.users.assignRole(assignedRole);
// Continue with your app's registration logic
res.status(201).json({ message: 'User registered and role assigned', user, response });
} catch (err) {
console.error('Registration failed:', err);
res.status(500).json({ error: 'Registration failed' });
}
});
The user's email address is the user key in Permit.io. Your app passes the same key to permit.check().
Protect the /posts endpoint with permit.check()
Add the following code to server.js. The POST /posts handler asks the PDP whether the user in the request body can create a Post, and returns 403 when the PDP denies the request.
app.post('/posts', async (req, res) => {
const { user } = req.body;
const action = 'create';
const resource = 'Post';
if (!user) {
return res.status(400).json({ error: 'Missing required fields' });
}
// Check if the user is allowed to create a post
try {
const permitted = await permit.check(user, action, resource);
if (permitted) {
// ... proceed with creating the post
res.json({ message: 'You are authorized to create a post' });
} else {
res.status(403).json({ message: 'You are not authorized to create a post' });
}
} catch (err) {
console.error('Error creating post:', err);
res.status(500).json({ error: 'Error creating post' });
}
});
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
permit.check() takes the user key, the action, and the resource type, and resolves to true or false. To protect other endpoints, such as commenting or editing, change the action and resource values.
In a production app, take the user key from your authenticated session. This example reads the user key from the request body so that you can test the endpoint with curl.
Start the Express.js app
Set the environment variables and start the app, replacing <YOUR_API_KEY> with your API key:
export PERMIT_API_KEY=<YOUR_API_KEY>
export PDP_URL=http://localhost:7766
node server.js
The app listens on http://localhost:8000.
5. Test the permission check
Register two users, give one of them the Author role, and confirm that the PDP allows only that user to create a post.
Register two users
In a second terminal, register John and Emma:
curl -X POST http://localhost:8000/register \
-H "Content-Type: application/json" \
-d '{"email": "john@example.com", "first_name": "John", "last_name": "Doe"}'
curl -X POST http://localhost:8000/register \
-H "Content-Type: application/json" \
-d '{"email": "emma@example.com", "first_name": "Emma", "last_name": "Den"}'
Each request returns "message": "User registered and role assigned", the synced user under user, and the role assignment under response, with "role": "Reader" and "tenant": "default".

Assign John the Author role
Both users have the Reader role, which can't create posts. Give John the Author role in the Permit dashboard:
- Open the Directory screen and select
john@example.comto open the Edit User panel. - Under Permissions Per Tenant, select the Default Tenant.
- In Top Level Access, add the Author role.
- Click Save.

For other ways to assign roles, including the API and SDK, see Sync users.
Check that John can create a post and Emma can't
Send a POST /posts request for John:
curl -X POST http://localhost:8000/posts \
-H "Content-Type: application/json" \
-d '{"user": "john@example.com"}'
The PDP allows the request because John has the Author role. The app returns {"message":"You are authorized to create a post"}.

Send the same request for Emma:
curl -X POST http://localhost:8000/posts \
-H "Content-Type: application/json" \
-d '{"user": "emma@example.com"}'
The PDP denies the request because Emma has only the Reader role. The app returns HTTP 403 with {"message":"You are not authorized to create a post"}.

Each check also appears in the Audit Log screen of the Permit dashboard, with the user, action, resource, and decision.
6. Verify JWTs and let authors edit only their own posts
A production API receives a signed JSON Web Token (JWT) instead of a user key in the request body. Verify the token, read the user from the sub claim, and pass that value to permit.check(). The sub claim must equal the user key in Permit. In this tutorial the user key is the email address, so a token for John has sub set to john@example.com. Permit checks the roles assigned in Permit, so the token only identifies the user and doesn't carry roles.
Verify the token in middleware
Install jsonwebtoken:
npm install jsonwebtoken
Create a file named auth.js. The authenticate middleware returns 401 when the token is missing or invalid, and stores the verified claims on req.user:
// auth.js
const jwt = require('jsonwebtoken');
function authenticate(req, res, next) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: 'Missing bearer token' });
try {
// verify() checks the signature and the exp claim
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
}
module.exports = { authenticate };
This example verifies tokens signed with a shared secret in the JWT_SECRET environment variable. If your identity provider signs tokens with a key pair, pass its public key to jwt.verify() instead. See the jsonwebtoken documentation.
Add an authorize middleware
In server.js, below the Permit client, add authorize(). It runs permit.check() for the user in the verified token and returns 403 when the policy denies the action:
// server.js
const { randomUUID } = require('crypto');
const { authenticate } = require('./auth');
// Run permit.check() for the user in the verified JWT
function authorize(action, resourceFor) {
return async (req, res, next) => {
try {
const permitted = await permit.check(req.user.sub, action, resourceFor(req));
if (!permitted) return res.status(403).json({ message: 'Forbidden' });
next();
} catch (err) {
console.error('Permission check failed:', err);
res.status(500).json({ error: 'Permission check failed' });
}
};
}
Create a post and make its creator the author
The POST /api/posts route checks the top-level create permission on the Post type. It then registers the new post as a resource instance and assigns the creator the Author role on that post only. These routes use the /api/posts prefix, so they don't replace the /posts route from step 4:
// server.js
app.post('/api/posts', authenticate, authorize('create', () => 'Post'), async (req, res) => {
const id = randomUUID();
try {
await permit.api.resourceInstances.create({ key: id, resource: 'Post', tenant: 'default' });
// The creator becomes the Author of this post only
await permit.api.roleAssignments.assign({
user: req.user.sub,
role: 'Author',
resource_instance: `Post:${id}`,
tenant: 'default',
});
res.status(201).json({ id });
} catch (err) {
console.error('Could not register the post in Permit:', err);
res.status(500).json({ error: 'Could not create the post' });
}
});
Check the user against a single post
The update and delete routes pass the post ID, so the PDP evaluates the user's roles on that post as well as the top-level roles. Add the routes below the create route:
// server.js
const post = (req) => ({ type: 'Post', key: req.params.id, tenant: 'default' });
app.put('/api/posts/:id', authenticate, authorize('update', post), (req, res) => {
res.json({ message: `Post ${req.params.id} updated` });
});
app.delete('/api/posts/:id', authenticate, authorize('delete', post), (req, res) => {
res.json({ message: `Post ${req.params.id} deleted` });
});
The blogging platform template gives the Admin role every action on Post. A top-level role applies to every post, and a role on a post applies to that post only. The template also gives the Author role on a post the delete action. To make delete an Admin-only action, clear delete for the Author role on a Post instance in the Policy Editor. The change takes effect without a redeploy.
Test the routes with signed tokens
Stop the app. Set JWT_SECRET in the same shell as PERMIT_API_KEY and PDP_URL, then start the app again:
export JWT_SECRET=$(openssl rand -hex 32)
node server.js
In a second terminal that has the same JWT_SECRET, create tokens for John, who has the Author role from step 5, and for Emma, who has the Reader role:
JOHN=$(node -e "console.log(require('jsonwebtoken').sign({ sub: 'john@example.com' }, process.env.JWT_SECRET, { expiresIn: '1h' }))")
EMMA=$(node -e "console.log(require('jsonwebtoken').sign({ sub: 'emma@example.com' }, process.env.JWT_SECRET, { expiresIn: '1h' }))")
John creates a post. The app returns 201 and the post ID. Emma then tries to edit John's post:
curl -s -X POST http://localhost:8000/api/posts -H "Authorization: Bearer $JOHN"
curl -s -X PUT http://localhost:8000/api/posts/<POST_ID> -H "Authorization: Bearer $EMMA"
Replace <POST_ID> with the id from John's response. Emma's request returns 403 with {"message":"Forbidden"}. The same request with John's token returns {"message":"Post <POST_ID> updated"}, because John is the Author of that post. A request without a token returns 401.
Frequently asked questions
How do I add role-based authorization to an Express API that uses JWT?
Verify the JWT in middleware, read the user from the sub claim, and call permit.check(user, action, resource) before the route handler runs. Keep the roles and permissions in Permit, not in the token, so a role change doesn't require issuing new tokens. Section 6 shows the complete middleware and routes.
How do I let only admins delete and members edit only their own resources?
Give admins a top-level role that has the delete action, and give the creator of each resource a role on that resource instance only. The PDP checks the top-level roles and the roles on the instance. The DELETE route in section 6 asks for delete on the post, so only users whose roles include delete pass.
Do I need to redeploy when a role or permission changes?
No. Roles, permissions, and role assignments live in Permit. Change them in the Policy Editor or the Directory, and the next permit.check() call uses the updated policy. Updates reach a PDP in the background, so repeat the request if you see the previous result.
Should I use Permit.io or an in-process library such as CASL?
Use an in-process library when the rules live in one service, change only with a code release, and need no audit trail or dashboard. Use Permit.io when roles change without a redeploy, several services share the same rules, or non-developers manage access. CASL also works next to Permit for showing and hiding UI. See Enforce permissions in the frontend with CASL.
Where does the PDP run?
This tutorial runs the PDP as a container next to the app, at http://localhost:7766. You can also use the Cloud PDP, which supports RBAC and ReBAC. See Cloud PDP capabilities.
What does the free tier include?
The Community plan on the Permit.io pricing page lists MAU 1000, Tenants 20, Authorization Queries No Limit, and PDP Instances No Limit.
Next steps
- Check permissions with the Node.js SDK: SDK installation, configuration, and more
permit.check()examples. - Check permissions with permit.check(): check against tenants, resource instances, and attributes.
- Build RBAC policies: create roles and permissions for your own resources.
- Embed user management with Permit Elements: let your users manage roles from your app.