Skip to main content

Hanko and Permit

Build a Next.js notes app that signs users in with Hanko passkeys and uses Permit.io to decide who can read, create, update, and delete notes. This tutorial is for developers who use Hanko, or plan to, and want to add authorization. You start with role-based access control (RBAC), sync Hanko users into Permit, and then limit updates and deletes to the note's owner with attribute-based access control (ABAC).

For background on passkeys and fine-grained authorization, read the Permit blog post Better access control with passkeys and fine-grained authorization.

What you build

  • Passkey sign-in with Hanko Cloud, enforced by Next.js middleware.
  • A Permit policy where every user can read and create notes, and only admins can update and delete them.
  • A sync route that adds each Hanko user to Permit with the user role.
  • An ABAC rule that lets users update and delete the notes they own.

The app is written in Next.js. Permit has SDKs for other languages, so you can apply the same pattern in another stack. The full code is in the permit-hanko repository.

Prerequisites

Already using Hanko?

If your application already signs users in with Hanko, skip to Set up basic role-based authorization.

Run the demo application

Clone the demo application to follow the steps with working code.

  1. Clone the application:
git clone git@github.com:permitio/permit-hanko.git
cd permit-hanko
  1. Install the dependencies:
npm install
  1. Run the application:
npm run dev

The application shows a configuration error page until you set the Hanko API URL in the next section.

Configuration error page listing the NEXT_PUBLIC_HANKO_API_URL environment variable

Set up Hanko passkey authentication

You can run Hanko locally or use Hanko Cloud. These steps use Hanko Cloud.

  1. Open Hanko Cloud, create an organization, and give it a name.

Hanko Cloud form to create an organization

  1. In the dashboard, create a project and set the App URL to http://localhost:3000.

Hanko Cloud form to create a project with the App URL field

  1. In the project, open Settings > General and copy the API URL.

Hanko project General settings with the read-only API URL field

  1. Create a file named .env.local in the root of the application and add the API URL. Replace the example value with your project's API URL:
NEXT_PUBLIC_HANKO_API_URL=https://a0ae8d5d-9505-415f-ad70-51839c285726.hanko.io
  1. Restart the application. The configuration error is gone, and the login page appears.

Hanko passkey login page in the notes app

How the app uses Hanko

The login page renders the Hanko authentication component from the Hanko JavaScript SDK. See app/auth/login/page.tsx:

<Paper sx={{ p: 2 }}>
<HankoAuth />
</Paper>

The middleware in middleware.ts reads the hanko cookie, verifies the token against the Hanko JSON Web Key Set (JWKS), and uses the token's sub claim as the user ID. If the token is missing or invalid, the middleware sends the user to the login page:

const authenticateUser = async (req: NextRequest): Promise<string> => {
if (!hankoApiUrl) {
return "";
}

// Get Hanko token from cookie
const hanko = req.cookies.get("hanko")?.value;

...

// Authenticate user using Hanko
const user = await authenticateUser(req);

// Redirect to login page if user is not authenticated
if (!user) {
urlToRedirect.pathname = LOGIN_URL;
return NextResponse.rewrite(urlToRedirect);
}

Set up basic role-based authorization

The notes API in /app/api/notes/route.ts has four handlers: GET, POST, PUT, and DELETE. They read, create, update, and delete notes.

The handlers don't check permissions themselves. The same middleware.ts file sends every request to the PDP's /allowed endpoint, which is the endpoint that permit.check() calls:

const response = await fetch(`${pdpUrl}/allowed`, {
method: "POST",
headers: {
Authorization: `Bearer ${permitApiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
user: user,
action,
resource: resource,
context: {},
}),
});

The request passes three values to the PDP:

ValueIn the demo app
userThe Hanko user ID from the verified token
actionThe HTTP method in lowercase: get, post, put, or delete
resourceThe resource type from the request path (notes). For requests other than GET, the middleware also sends the JSON request body, the note, as resource attributes.
The middleware allows every request until you set a Permit API key

When PERMIT_API_KEY is not set, the middleware skips the check and allows all requests. Set the key before you test the policy.

Create the policy in Permit

  1. Sign in to the Permit dashboard and open the environment you want to use.
  2. Open Policy, go to the Roles tab, and create two roles: admin and user.

Permit Roles tab with the admin and user roles

  1. In the Resources tab, create a resource named notes with the actions get, post, put, and delete, and an owner attribute.

Permit resource form for notes with four actions and an owner attribute

  1. In the Policy Editor tab, allow get and post on notes for both roles. Allow put and delete only for admin.

Permit Policy Editor where user can get and post notes and admin has all four actions

Add your Permit credentials to the app

  1. Copy your environment API key. See Get your API key.
  2. Add the key to the .env.local file:
PERMIT_API_KEY=<YOUR_COPIED_API_KEY>
  1. Set the PDP URL. This section uses the Cloud PDP:
PERMIT_PDP_URL=https://cloudpdp.api.permit.io

If you leave PERMIT_PDP_URL unset, the middleware uses the Cloud PDP by default.

  1. Restart the application so it loads the environment variables.

Sync Hanko users to Permit.io

The PDP can only match a user to a role if the user exists in Permit. The demo app syncs each user after Hanko sign-in: the Hanko component calls the /api/permit route when the authentication flow completes. The route gives every new user the user role in the default tenant, so new users start with the least privilege in the policy.

The code is in src/app/api/permit/route.ts:

const response = await permit.api.syncUser({

key,
  email,
  attributes: {
    roles: ["user"],
  },
});

await permit.api.roleAssignments.assign({

role: "user",
  tenant: "default",
  user: key,

});

The route first looks the user up with permit.api.getUser(). It runs the sync and the role assignment only for users that don't exist in Permit yet.

Verify the RBAC policy

  1. Open http://localhost:3000 and sign in with Hanko. Create a passkey when Hanko asks for one.
  2. Create a note. The note appears in the list.
  3. In the Permit dashboard, open Audit Log. A post entry for your user shows an allowed decision, because the user role allows post on notes.

Permit Audit Log entry for an allowed post action on notes

  1. Delete the note. The app shows an error: You are not allowed to access this resource. The user role doesn't allow delete.
  2. In the Permit dashboard, open Directory and assign the admin role to your user in the default tenant.
  3. Delete the note again. The delete succeeds, and you didn't change any application code.

Permit user list where the first user has the User and Admin roles

Add fine-grained attribute-based authorization

With the RBAC policy, only admins can update and delete notes. In this section, you also let each user update and delete the notes they own. This rule needs the note's owner attribute, so you add an ABAC rule.

ABAC rules need a container PDP

The Cloud PDP doesn't support ABAC policies, so it can't apply the owner rule in this section. Run the PDP as a container and set PERMIT_PDP_URL to its address. See Deploy the PDP to production and Cloud PDP capabilities.

  1. In the Permit dashboard, open Policy > ABAC Rules and enable ABAC.

Permit ABAC Rules tab with the ABAC option enabled

  1. Create a resource set named Owned Notes (key owned_notes) on the notes resource type. Add the condition resource.owner equals (ref) user.key. The set matches a note only when its owner attribute equals the key of the user who makes the request.

Permit Edit Resource Set dialog for Owned Notes with the condition resource.owner equals user.key

  1. In the Policy Editor, allow put and delete on the Owned Notes resource set for the user role. Keep put and delete on notes for the admin role, so admins can change any note.

Permit Policy Editor with actions allowed on the Owned Notes resource set for the Admin and User roles

Verify the ABAC policy

The first user from the RBAC section has the admin role. Sign up a second user to test the owner rule.

  1. Sign out, then sign up with a second user. The sync route gives the second user the user role.
  2. As the second user, delete a note that the first user created. The delete fails. The Audit Log shows a denied delete decision for the second user.

Permit Audit Log with a denied delete decision on notes for the second user

  1. As the second user, create a note, then delete it. The delete succeeds, because the second user owns the note.
  2. Create another note as the second user. Sign out, and sign in as the first user.
  3. As the first user, delete the second user's note. The delete succeeds, because the first user has the admin role.

Check permissions inside application logic

The middleware checks each API request. Some decisions belong inside your application logic, for example an operation that only premium users can run. Call permit.check() at that point in the code. The policy stays in Permit, so a policy change doesn't need a code change.

const permit = new Permit({ token: process.env.PERMIT_API_KEY });

const permitted = await permit.check(user, action, resource);

The client reads your environment API key from PERMIT_API_KEY. Inside the application logic, permit.check() can use data that isn't in the HTTP request, such as attributes you load from your database.

Next step: relationship-based authorization

Relationship-based access control (ReBAC) grants access through relationships between resources. For example, a notes app with workspaces and folders might have no owner field on a note, but each note belongs to a workspace, and the user's role in the workspace decides access to its notes. You configure ReBAC policies in Permit, and the permit.check() calls and middleware in the app stay the same.