This week we released the first two packages under AdonisJS Plus: @adonisplus/persona and @adonisplus/permission.
Persona provides Lucid model mixins for common user account operations. It covers email verification flows, password resets, and two-factor authentication with authenticator app enrollment, QR codes, and recovery codes. Each module is independent, so you can use email management without password management, or add TOTP later without touching your existing setup.
Permission provides a declarative role-based permission system with Bouncer integration. You define permissions using a resource and action pattern, assign them to models or through roles, and check access through the Access class or auto-generated Bouncer abilities.
These packages are the foundation on which Kit components are built. You will see this pattern repeat as the Kit components catalogue grows. If you are new to AdonisJS Plus, it is a commercial product built on top of AdonisJS. You can learn more at plus.adonisjs.com.
What are Kit components
Kit components are full-stack, copy-paste components for AdonisJS applications. Each component covers the entire stack: the UI, the controllers, the validation, and the routes. You copy it into your project and own it from that point forward.
We are building Kit components for Vue, React, Edge, and API-only applications, so you can use them regardless of your frontend setup.
The philosophy is borrowed from what shadCN proved with the frontend, high-level code that you are likely to modify belongs in your application, not locked inside a dependency. Want to change a redirect after login? Change the success message? Add an extra step to your registration flow? You can, because the code is right there in your project. There is no configuration API to fight, no overrides, no escape hatches. You just edit the file.
The case for keeping high-level code in your application
The simplest version of this approach is to put everything inside the component. The controller handles the entire flow, top to bottom. You copy it in, it works, you move on.
The problem shows up later.
Low-level operations like generating verification tokens, switching emails, creating 2FA secrets, cleaning up expired tokens, and hashing passwords are complex, security-sensitive, and nearly identical across every project. When this logic lives inside a component you copied into your codebase, it becomes your responsibility to understand, maintain, and keep secure.
If there is a bug or a security concern, there is no package to update. You have to find the relevant code across every project that copied that component and change it manually. That does not scale, and it is exactly the kind of update that gets missed.
Where packages fit in
The opposite approach is also tempting, put everything in a package. Ship the entire flow as an installable dependency and let developers configure what they need.
This works until the moment someone needs to change something. And on high-level flows, that moment comes quickly.
Endpoint naming, redirect behaviour, success and error messages, email templates, UI choices. These are decisions that vary between projects and change over time. If they live inside a package, every variation needs a configuration knob. The package grows into something that tries to anticipate every possible modification, and it never succeeds. The developer ends up fighting the abstraction instead of just writing code.
The frontend industry learned this the hard way. The answer shadCN arrived at is to keep the parts that change frequently in your application, and keep the parts that are complex, reusable, and security-sensitive in packages. We are applying the same thinking to full-stack and backend components.
The boundary is straightforward. Generating a token does not change between projects. Where you redirect after login does. Persona and Permission own the first half. Kit components own the second.
A glimpse of how the packages work
Both packages follow the same pattern, apply a mixin to your Lucid model and get a clean API in return. The complexity stays inside the package. Your application code stays readable.
Email management
Applying the withManagedEmail mixin to your User model takes one line.
import { compose } from '@adonisjs/core/helpers'
import { withManagedEmail } from '@adonisplus/persona/email'
export default class User extends compose(
BaseModel,
withManagedEmail()
) {}
From that point, creating a user with an unverified email and issuing a verification token looks like this.
const { user, token } = await db.transaction(async (trx) => {
const newUser = await User.create(
{ email, unverifiedEmail: email, password },
{ client: trx }
)
const verificationToken = await newUser.createEmailVerificationToken()
return { user: newUser, token: verificationToken }
})
// Send token.value!.release() to the user via email
When the user clicks the verification link, verifying the email and cleaning up tokens is two lines.
const user = await User.verifyEmail(token)
await user.clearEmailVerificationTokens()
Under the hood, verifyEmail handles token validation, expiry checks, and verifies that no other user already owns the email, all inside a database transaction. The mixin also uses a two-column pattern, with email for the verified primary address and unverified_email for a pending change. This makes email change flows safe by preserving the original address until the new one is confirmed.
Two-factor authentication
The withTotpManagement mixin follows the same approach. Apply it to your User model alongside the other mixins.
import { compose } from '@adonisjs/core/helpers'
import { withManagedEmail } from '@adonisplus/persona/email'
import { withManagedPassword } from '@adonisplus/persona/password'
import { withTotpManagement } from '@adonisplus/persona/totp'
export default class User extends compose(
BaseModel,
withManagedEmail(),
withManagedPassword(),
withTotpManagement(encryption, hash)
) {}
Enrolling a user in 2FA, generating a QR code for their authenticator app, and issuing recovery codes then becomes straightforward.
const { secret, qrCode } = await user.createTotpSecret()
const backupCodes = await user.generateBackupCodes()
// Show qrCode to the user in your UI
// Store backupCodes safely
Getting 2FA right, with secrets, QR codes, backup codes, and replay attack prevention, has enough edge cases that it is worth not writing it yourself.
Role-based permissions
AdonisJS already ships with Bouncer, which is excellent for complex, conditional authorization like ownership checks, subscription status, and time-based rules. But many applications also need a simpler layer where any user with the right role and permission can perform an action, with no additional conditions to evaluate. That is what the permissions package is for.
You define your permissions once, in one place.
import { definePermissions } from '@adonisplus/permissions'
export const permissions = definePermissions({
product: {
create: 'Create new products',
update: 'Update existing products',
delete: 'Delete products permanently',
},
billing: {
refund: 'Issue refunds to customers',
},
})
Apply withPermissions to your Role model and withRoles to your User model.
export default class Role extends compose(RoleSchema, withPermissions()) {}
export default class User extends compose(
UserSchema,
withRoles({ roleModel: () => Role, pivotTable: 'user_roles' })
) {}
With the models in place, you can create roles, assign permissions to them, and then assign those roles to users.
import { permissions } from '#start/permissions'
import Role from '#models/role'
import User from '#models/user'
// Create a role and assign permissions to it
const editor = await Role.create({ name: 'editor' })
await editor.givePermissions([
permissions.getKey('product.create'),
permissions.getKey('product.update'),
])
// Assign the role to a user
const user = await User.findOrFail(1)
await user.assignRole(editor)
The most direct way to guard a route is through the authorize middleware. Pass the permission key and the middleware handles the rest, throwing a 403 if the user does not have the required permission.
router
.post('/products', [ProductsController, 'store'])
.use(middleware.authorize('product.create'))
router
.delete('/products/:id', [ProductsController, 'destroy'])
.use(middleware.authorize('product.delete'))
The same permission checks are also available through Bouncer, which means you can use them inside controllers and Edge templates without any additional setup. The package generates Bouncer abilities automatically from your permission definitions. Register them once in your Bouncer middleware.
const permissionAbilities = permissions.abilities()
ctx.bouncer = new Bouncer(
() => ctx.auth.user || null,
{ ...abilities, ...permissionAbilities },
policies
)
In a controller, the check looks like any other Bouncer ability.
export default class ProductsController {
async store({ bouncer }: HttpContext) {
await bouncer.authorize('product.create')
// proceed
}
async destroy({ bouncer }: HttpContext) {
await bouncer.authorize('product.delete')
// proceed
}
}
And in an Edge template, you can conditionally render UI based on the same permissions.
@can('product.create')
<a href="/products/new">Add product</a>
@end
@can('product.delete')
<button type="submit">Delete</button>
@end
All permission keys are type-safe and autocompleted by your editor. A user with an editor role can create products but gets a 403 when trying to delete them. A user with an admin role can do both. No hand-written ability functions needed for this layer.
What's next
Persona and Permission are the foundation. Kit components are what gets built on top of them.
Kit components are the high-level flows, registration pages, login pages, 2FA setup, role management UI. Each one is a full-stack, copy-paste component that uses these packages under the hood. You get the complete flow without writing it from scratch, and because the code lives in your application, you can modify any part of it freely.
Kit components are currently in progress and will start shipping next.
Getting access
Persona and Permission are available to all AdonisJS Plus subscribers. If you are already a subscriber, head to your dashboard to find your personal access token and the documentation for both packages.
If you are not a subscriber yet, early bird pricing is still open at $149, which is $100 off the regular price. You get access to both packages, full documentation, and everything that ships next.