# Permissions that grow with the product

By [Anubhav Choubey](https://anubhavchoubey.com/) · Cloud & DevOps · 2026-11-04 · 4 min read

> Replacing hard-coded role checks with granular CASL permissions, plus TOTP two-factor, backup codes and OTP limits that lock out abuse, not users.

```stats
Roles become templates · CASL checks at request time · TOTP + backup codes · OTP lockout
```

## The problem

The dashboard decided who could do what with role checks spread through the code: "is this user an owner?", "is this user an admin?". That works with three roles and ten screens. It stops working when the product grows. Every new feature needs a new rule, every customer wants a slightly different role, and nobody can answer "what can an editor actually do?" without reading the code.

Login needed the same care: a second factor for people who want one, and sensible limits on emailed one-time codes.

## From roles to permissions

I replaced the role middleware with a permissions-first system built on [CASL](https://casl.js.org). Each user's access to a site became a set of permission rows, and every route asks one question:

```js
if (!req.ability.can('manage', 'Deploys')) return res.status(403).end()
```

Permissions are grouped into buckets such as deploys, content, infrastructure, team and analytics, with a deliberate **view / manage split**. Every collaborator can see how something is configured. Only people who hold the manage permission can change it.

The key decision was what to do with roles. They did not disappear. They became **write-time templates**: picking "Editor" stamps out a set of permission rows when the user is added. Nothing at request time ever looks at a role. That made a "Custom" role free, because it is just a different set of rows, and it means the old role field can be dropped later without touching authorization.

## Shipping it without locking people out

A permission system that ships without data for existing users locks everyone out on deploy. So I split the work:

1. **Schema first.** The new tables and a backfill of view permissions for every existing member landed on their own, before any code read them.
2. **Then the code.** The CASL checks replaced the role middleware once every user already had rows.
3. **Then the UI.** Team management moved into its own settings tab with per-permission checkboxes. That page was itself gated by permissions: one to invite and remove, another to edit permissions, read-only for everyone else.

The old share modal and its components were deleted, not hidden, so there was only one way to manage a team.

```callout warn
A permission only works if every endpoint in the flow agrees. One user had exactly the right permission to create a workspace, but the modal also fetched repository settings from an endpoint still gated on owner. The fetch failed and the feature looked broken. Test whole flows per role, not single endpoints.
```

## Two-factor with a partial token

I added TOTP two-factor authentication (any authenticator app) with backup codes. The flow is built on a **partial JWT**:

```text
password ok ──▶ short-lived partial token ──▶ TOTP or backup code ──▶ full token
```

The security is in the details more than the maths:

- A middleware makes sure a partial token can never be used as a full one.
- Sensitive operations ask for a fresh TOTP code, so a long-lived session cannot quietly do privileged things.
- Verification attempts are rate limited.
- TOTP secrets are encrypted at rest.

Users got a setup wizard with a QR code, a way to disable 2FA, and backup code management.

## OTP limits that punish abuse, not people

Emailed codes can be abused two ways: flooding someone else's inbox, and guessing codes. I made the limit per user, not per IP. Each request counts against the user inside a rolling window. After a small number of codes, further requests are blocked until the window ends, and the user sees a clear lock screen with a support address instead of a dead-end error.

The detail I like most: **a successful login resets the counter**. Someone who fumbled a few codes and then got in is not punished later.

I also made the codes harder to guess and easier to type. They switched from digits to uppercase letters and numbers, without look-alike characters such as `O` and `0`, generated with a cryptographic random source. The input uppercases what you type, so reading a code off a phone just works.

## What I'd do differently

- **Design the permission model before the first role check.** The migration was safe, but most of its cost was finding every hidden role check.
- **Add a per-role flow test from the start.** Walking every screen as every preset role would have caught the endpoint mismatches before users did.
- **Keep secrets out of default query results.** A good rule is that secret columns are never returned unless a query asks for them by name, set once in the database client.

This model also became the base for the OAuth layer described in [authentication for people and agents](/blog/mcp-auth-for-people-and-agents): tokens there are derived from the same permission rows.

## Takeaway

Treat roles as a shortcut for creating permissions, not as the thing you check. The product can then add features, roles and custom setups without touching authorization, and "what can this person do?" has an answer you can read.
