npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@kenzuya/honest-better-auth

v1.0.0

Published

Better Auth for HonestJS

Readme

Honest Better Auth Integration

Better Auth for Honest (@kenzuya/honest), the Nest-style framework on Hono. It mounts Better Auth's routes, guards your controllers with decorators, gives you the session in handlers, and wires Better Auth hooks into Honest's dependency injection.

This package is a port of @thallesp/nestjs-better-auth to Honest. See Migrating from nestjs-better-auth for the differences.

Installation

# Using bun
bun add @kenzuya/honest-better-auth @kenzuya/honest better-auth hono reflect-metadata

# Using npm
npm install @kenzuya/honest-better-auth @kenzuya/honest better-auth hono reflect-metadata

Prerequisites

[!IMPORTANT] Requires better-auth >= 1.7.0 and @kenzuya/honest 0.1.x.

  • A working Honest application
  • Better Auth (>= 1.7.0) installed and configured (installation guide)
  • "experimentalDecorators": true and "emitDecoratorMetadata": true in your tsconfig.json (Honest's dependency injection needs them)

Basic Setup

1. Create your Better Auth instance

import { betterAuth } from "better-auth";

export const auth = betterAuth({
  basePath: "/api/auth", // the default
  emailAndPassword: { enabled: true },
  // database, plugins, ...
});

2. Register the plugin and the guard

Add BetterAuthPlugin to the application's plugins and AuthGuard to its global guards:

import "reflect-metadata";
import { Application } from "@kenzuya/honest";
import { AuthGuard, BetterAuthPlugin } from "@kenzuya/honest-better-auth";
import { AppModule } from "./app.module";
import { auth } from "./auth";

const { hono } = await Application.create(AppModule, {
  plugins: [new BetterAuthPlugin({ auth })],
  components: { guards: [AuthGuard] },
});

export default hono;

The plugin:

  • mounts Better Auth's handler on the path Better Auth routes on: the path of baseURL (or BETTER_AUTH_URL) when it has one, otherwise basePath (/api/auth/* by default). The routing.prefix and routing.version options of your app do not apply to it; change the path in your Better Auth config. With basePath: "/" the handler is mounted after your controllers, so their routes take precedence and Better Auth answers the rest.
  • registers AuthService and AuthGuard in the application's DI container.
  • applies CORS for the origins Better Auth trusts on its routes (see CORS).
  • wires @Hook() and @DatabaseHook() services into Better Auth.

Hono hands Better Auth the original Web Request, so there is no body parser to disable or configure. Middleware that reads the body with Hono's c.req.json()/c.req.text() before Better Auth is fine: the plugin rebuilds the request from Hono's body cache.

Route Protection

With AuthGuard in components.guards, every route requires a session unless you allow anonymous access with @AllowAnonymous() or make authentication optional with @OptionalAuth(). Requests without a session get 401 Unauthorized; failed role or permission checks get 403 Forbidden.

To protect only some controllers, leave AuthGuard out of the global guards and apply it with @UseGuards():

import { Controller, Get, UseGuards } from "@kenzuya/honest";
import { AuthGuard } from "@kenzuya/honest-better-auth";

@UseGuards(AuthGuard)
@Controller("users")
export class UsersController {
  @Get("me")
  getProfile() {
    return { message: "Protected route" };
  }
}

The plugin must be registered either way, because the guard gets the Better Auth instance from it.

Decorators

Session Decorator

Access the user session in your controllers:

import { Controller, Get } from "@kenzuya/honest";
import { Session, type UserSession } from "@kenzuya/honest-better-auth";

@Controller("users")
export class UsersController {
  @Get("me")
  getProfile(@Session() session: UserSession) {
    return session;
  }
}

Pass your auth type for plugin fields: UserSession<typeof auth> types fields added by plugins such as username or admin.

AllowAnonymous and OptionalAuth Decorators

Control authentication requirements for specific routes:

import { Controller, Get } from "@kenzuya/honest";
import {
  AllowAnonymous,
  OptionalAuth,
  Session,
  type UserSession,
} from "@kenzuya/honest-better-auth";

@Controller("users")
export class UsersController {
  @Get("public")
  @AllowAnonymous() // Allow anonymous access (no authentication required)
  publicRoute() {
    return { message: "This route is public" };
  }

  @Get("optional")
  @OptionalAuth() // Authentication is optional for this route
  optionalRoute(@Session() session: UserSession | null) {
    return { authenticated: !!session, session };
  }
}

Alternatively, use them as class decorators for an entire controller:

@AllowAnonymous() // All routes inside this controller are public
@Controller("public")
export class PublicController {
  /* */
}

@OptionalAuth() // Authentication is optional for all routes
@Controller("optional")
export class OptionalController {
  /* */
}

A decorator on a route overrides the same decorator on its controller.

Role-Based Access Control

This library provides two role decorators for different use cases:

| Decorator | Checks | Use Case | |-----------|--------|----------| | @Roles() | user.role only | System-level roles (admin plugin) | | @RequireActiveOrg() | Active organization only | Routes that only need activeOrganizationId for scoping | | @OrgRoles() | Active organization + organization member role | Organization-level roles (organization plugin) |

[!IMPORTANT] These decorators are intentionally separate to prevent privilege escalation. The @Roles() decorator only checks user.role and does not check organization member roles. This ensures an organization admin cannot bypass system-level admin protection.

@Roles() - System-Level Roles

Use @Roles() for system-wide admin protection. This checks only the user.role field from Better Auth's admin plugin.

import { Controller, Get } from "@kenzuya/honest";
import { Roles } from "@kenzuya/honest-better-auth";

@Controller("admin")
export class AdminController {
  @Roles(["admin"])
  @Get("dashboard")
  async adminDashboard() {
    // Only users with user.role = 'admin' can access
    // Organization admins CANNOT access this route
    return { message: "System admin dashboard" };
  }
}

// Or as a class decorator
@Roles(["admin"])
@Controller("admin")
export class AdminController {
  /* All routes require user.role = 'admin' */
}

@RequireActiveOrg() - Active Organization Only

Use @RequireActiveOrg() when a route or controller only needs an active organization context. This requires authentication and session.activeOrganizationId, but does not require any specific organization role.

import { Controller, Get } from "@kenzuya/honest";
import {
  RequireActiveOrg,
  Session,
  UserSession,
} from "@kenzuya/honest-better-auth";

@RequireActiveOrg()
@Controller("projects")
export class ProjectsController {
  @Get()
  async listProjects(@Session() session: UserSession) {
    return { orgId: session.session.activeOrganizationId };
  }
}

Use this when the controller only needs an active organization ID for scoping data, but authorization is handled elsewhere or organization roles are dynamic.

@OrgRoles() - Organization-Level Roles

Use @OrgRoles() for organization-scoped protection when you want to require an active organization and one of the specified organization member roles.

import { Controller, Get } from "@kenzuya/honest";
import { OrgRoles, Session, UserSession } from "@kenzuya/honest-better-auth";

@Controller("org")
export class OrgController {
  @OrgRoles(["owner", "admin"])
  @Get("settings")
  async getOrgSettings(@Session() session: UserSession) {
    // Only org owners/admins can access (requires activeOrganizationId)
    // System admins (user.role = 'admin') CANNOT access without org context
    return { orgId: session.session.activeOrganizationId };
  }

  @OrgRoles(["owner"])
  @Get("billing")
  async getOrgBilling() {
    // Only org owners can access
    return { message: "Billing settings" };
  }
}

[!NOTE] Both role decorators accept any role strings you define. Better Auth's organization plugin provides default roles (owner, admin, member), but you can configure custom roles. The organization creator automatically gets the owner role.

Permission-Based Access Control

This library provides two permission decorators for fine-grained access control:

| Decorator | Checks | Use Case | |-----------|--------|----------| | @UserHasPermission() | User-level permissions | System-level permissions (admin plugin access control) | | @MemberHasPermission() | Organization member permissions | Organization-level permissions (organization plugin access control) |

@UserHasPermission() - System-Level Permissions

Use @UserHasPermission() for system-wide permission-based access control. This checks user permissions using Better Auth's admin plugin access control.

Prerequisites:

  • Configure access control in your Better Auth admin plugin
import { betterAuth } from "better-auth";
import { admin } from "better-auth/plugins/admin";
import { createAccessControl } from "better-auth/plugins/access";

const statement = {
  project: ["create", "share", "update", "delete"],
  sale: ["create", "read", "update", "delete"],
} as const;

const ac = createAccessControl(statement);

const editor = ac.newRole({
  project: ["create", "update"],
});

const adminRole = ac.newRole({
  project: ["create", "update", "delete"],
  sale: ["create", "read", "update", "delete"],
});

export const auth = betterAuth({
  plugins: [
    admin({
      ac,
      roles: {
        editor,
        admin: adminRole,
      },
    }),
  ],
});

Usage:

import { Controller, Get, Post } from "@kenzuya/honest";
import { UserHasPermission } from "@kenzuya/honest-better-auth";

@Controller("projects")
export class ProjectController {
  @UserHasPermission({ permission: { project: ["create", "update"] } })
  @Post()
  async createProject() {
    // Only users with project: ["create", "update"] permissions can access
    return { message: "Project created" };
  }

  @UserHasPermission({ permission: { project: ["delete"] } })
  @Post(":id/delete")
  async deleteProject() {
    // Only users with project: ["delete"] permission can access
    return { message: "Project deleted" };
  }

  @UserHasPermission({
    permissions: { project: ["create"], sale: ["create"] },
  })
  @Post("sales")
  async createSale() {
    // Requires both project: ["create"] and sale: ["create"]
    return { message: "Sale created" };
  }
}

Options:

  • permission: A single permission check (e.g., { project: ["create", "update"] })
  • permissions: Multiple permission checks across resources (e.g., { project: ["create"], sale: ["create"] })
  • role (server-only): Check permissions for a specific role
  • userId (optional): Check permissions for a specific user (defaults to current user)

@MemberHasPermission() - Organization-Level Permissions

Use @MemberHasPermission() for organization-scoped permission-based access control. This checks organization member permissions using Better Auth's organization plugin access control. Requires an active organization (activeOrganizationId in session).

Prerequisites:

  • Configure access control in your Better Auth organization plugin
  • Define custom organization roles with permissions
import { betterAuth } from "better-auth";
import { organization } from "better-auth/plugins/organization";
import { createAccessControl } from "better-auth/plugins/access";

const statement = {
  project: ["create", "share", "update", "delete"],
  sale: ["create", "read", "update", "delete"],
  organization: ["update", "delete"],
} as const;

const ac = createAccessControl(statement);

const editor = ac.newRole({
  project: ["create", "update"],
});

const admin = ac.newRole({
  project: ["create", "update", "delete"],
  organization: ["update"],
});

export const auth = betterAuth({
  plugins: [
    organization({
      ac,
      roles: {
        editor,
        admin,
      },
    }),
  ],
});

Usage:

import { Controller, Get, Post } from "@kenzuya/honest";
import { MemberHasPermission, Session, UserSession } from "@kenzuya/honest-better-auth";

@Controller("org/projects")
export class OrgProjectController {
  @MemberHasPermission({ permissions: { project: ["create", "update"] } })
  @Post()
  async createProject(@Session() session: UserSession) {
    // Only org members with project: ["create", "update"] permissions can access
    // Requires activeOrganizationId in session
    return {
      message: "Project created",
      orgId: session.session.activeOrganizationId,
    };
  }

  @MemberHasPermission({ permissions: { project: ["delete"] } })
  @Post(":id/delete")
  async deleteProject() {
    // Only org members with project: ["delete"] permission can access
    return { message: "Project deleted" };
  }
}

Options:

  • permissions: The permissions to check (required). Must match the structure in your organization access control.

Hook Decorators

Create Better Auth hooks as Honest services, so they can inject other services. No hooks option is needed in your betterAuth(...) config; your own hooks.before/hooks.after keep working alongside them.

import { Service } from "@kenzuya/honest";
import {
  BeforeHook,
  Hook,
  type AuthHookContext,
} from "@kenzuya/honest-better-auth";
import { SignUpService } from "./sign-up.service";

@Hook()
@Service()
export class SignUpHook {
  constructor(private readonly signUpService: SignUpService) {}

  @BeforeHook("/sign-up/email")
  async handle(ctx: AuthHookContext) {
    // Custom logic like enforcing email domain registration
    // Can throw APIError if validation fails
    await this.signUpService.execute(ctx);
  }
}

List your hooks in a module's services. The plugin wires the hook services that the application has instantiated:

import { Module } from "@kenzuya/honest";
import { SignUpHook } from "./hooks/sign-up.hook";
import { SignUpService } from "./hooks/sign-up.service";

@Module({
  services: [SignUpHook, SignUpService],
})
export class AppModule {}

Without a path, @BeforeHook() and @AfterHook() run for every Better Auth route.

The plugin registers these methods as hooks of a Better Auth plugin, so they behave like any Better Auth hook:

  • A @BeforeHook can return { context: { ... } } to change the request (for example the body), return a response such as ctx.json(...) to answer instead of the endpoint, or throw an APIError.
  • An @AfterHook can return a value to replace the endpoint's response.
  • They run after the hooks option of your config and after the hooks of plugins listed in plugins.

If several applications are created with the same Better Auth instance (for example in tests, or on a dev-server reload), the hooks of the latest application replace the earlier ones instead of running twice. The same applies to database hooks.

Database Hook Decorators

[!IMPORTANT] To use @DatabaseHook, @BeforeCreate, @AfterCreate, @BeforeUpdate, @AfterUpdate, @BeforeDelete, @AfterDelete, set databaseHooks: {} (empty object) in your betterAuth(...) config.

Database hooks let you hook into the lifecycle of core database operations (create, update, delete) for Better Auth models (user, session, account, verification).

Minimal Better Auth setup with database hooks enabled:

import { betterAuth } from "better-auth";

export const auth = betterAuth({
  // other better-auth options...
  databaseHooks: {}, // empty object is the minimum required
});

Create database hooks as Honest services:

import { Service } from "@kenzuya/honest";
import {
  AfterCreate,
  BeforeCreate,
  DatabaseHook,
} from "@kenzuya/honest-better-auth";
import { EmailService } from "./email.service";

@DatabaseHook()
@Service()
export class UserCreateHook {
  constructor(private readonly emailService: EmailService) {}

  @BeforeCreate("user")
  async beforeUserCreate(user) {
    return {
      data: {
        ...user,
        firstName: user.name.split(" ")[0],
        lastName: user.name.split(" ")[1],
      },
    };
  }

  @AfterCreate("user")
  async afterUserCreate(user) {
    await this.emailService.sendWelcomeEmail(user.email);
  }
}

Register your database hooks in a module:

import { Module } from "@kenzuya/honest";
import { UserCreateHook } from "./hooks/user-create.hook";
import { EmailService } from "./hooks/email.service";

@Module({
  services: [UserCreateHook, EmailService],
})
export class AppModule {}

Available method decorators:

| Decorator | Description | |-----------|-------------| | @BeforeCreate(model) | Runs before a record is created | | @AfterCreate(model) | Runs after a record is created | | @BeforeUpdate(model) | Runs before a record is updated | | @AfterUpdate(model) | Runs after a record is updated | | @BeforeDelete(model) | Runs before a record is deleted | | @AfterDelete(model) | Runs after a record is deleted |

Where model is one of: "user", "session", "account", "verification".

before hooks can return false to abort the operation, or { data: ... } to modify the data before it's written. after hooks are for side effects only.

AuthService

The plugin registers AuthService in the DI container. Inject it into controllers and services to use the Better Auth instance and its API endpoints:

import { Body, Controller, Get, Post, Req } from "@kenzuya/honest";
import { AuthService } from "@kenzuya/honest-better-auth";
import type { HonoRequest } from "hono";
import type { auth } from "../auth";

@Controller("users")
export class UsersController {
  constructor(private readonly authService: AuthService<typeof auth>) {}

  @Get("accounts")
  async getAccounts(@Req() req: HonoRequest) {
    // Pass the request headers to the auth API
    const accounts = await this.authService.api.listUserAccounts({
      headers: req.raw.headers,
    });

    return { accounts };
  }

  @Post("api-keys")
  async createApiKey(@Req() req: HonoRequest, @Body() body) {
    // Access plugin-specific functionality with request headers
    // createApiKey is a method added by a plugin, not part of the core API
    return this.authService.api.createApiKey({
      body,
      headers: req.raw.headers,
    });
  }
}

When using plugins that extend the Auth type with additional functionality, use generics to access the extended features as shown above with AuthService<typeof auth>. This ensures type safety when using plugin-specific API methods like createApiKey. authService.instance returns the whole Better Auth instance.

Context Variables

AuthGuard stores what it resolved in Hono context variables, available to handlers, later guards, pipes and filters:

| Variable | Value | | --------- | -------------------------------------------------------- | | session | The full session ({ session, user }), or null | | user | The session's user, or null (useful for observability tools like Sentry) |

import { Controller, Ctx, Get, Var } from "@kenzuya/honest";
import type { Context } from "hono";

@Controller("users")
export class UsersController {
  @Get("me")
  getProfile(@Var("user") user: unknown, @Ctx() c: Context) {
    return { user, session: c.get("session") };
  }
}

The variable names are exported as SESSION_CONTEXT_KEY and USER_CONTEXT_KEY.

Plugin Options

new BetterAuthPlugin({
  auth,
  disableTrustedOriginsCors: false,
  disableControllers: false,
  middleware: undefined,
});

| Option | Default | Description | | --------------------------- | ----------- | ----------- | | auth | (required) | Your Better Auth instance. | | disableTrustedOriginsCors | false | When true, does not apply CORS for the origins Better Auth trusts on its routes. | | disableControllers | false | When true, does not mount Better Auth's routes (or their CORS). AuthService, AuthGuard and hooks keep working. Use this to mount the handler yourself. | | middleware | undefined | Hono middleware (c, next) that runs before Better Auth's handler on its routes. |

CORS

The plugin applies hono/cors to Better Auth routes with credentials: true, allowing the origins Better Auth itself trusts: trustedOrigins (including wildcard patterns such as https://*.example.com and custom schemes), the origin of baseURL, BETTER_AUTH_TRUSTED_ORIGINS and origins added by plugins. Matching is done by Better Auth, so CORS and Better Auth's own origin check agree. A trustedOrigins function is called with the request, and only when the request has an Origin header.

The CORS middleware covers Better Auth routes only. Add your own CORS for the rest of your API, for example a global Honest middleware that wraps Hono's cors(). Set disableTrustedOriginsCors: true to manage CORS for Better Auth routes yourself.

Using Custom Middleware

middleware runs before Better Auth's handler, for example to set up request-scoped state:

new BetterAuthPlugin({
  auth,
  middleware: async (c, next) => {
    const startedAt = Date.now();
    await next();
    console.log(`${c.req.method} ${c.req.path} took ${Date.now() - startedAt}ms`);
  },
});

The middleware can also return a response itself to stop the request before it reaches Better Auth, and it can read the body through Hono (c.req.json(), c.req.text()). Errors it throws go to the application's onError handler.

Migrating from nestjs-better-auth

| NestJS (@thallesp/nestjs-better-auth) | Honest (@kenzuya/honest-better-auth) | | ------------------------------------------------- | -------------------------------------- | | AuthModule.forRoot({ auth }) / forRootAsync() | plugins: [new BetterAuthPlugin({ auth })] | | Global AuthGuard by default, disableGlobalAuthGuard | components: { guards: [AuthGuard] } or @UseGuards(AuthGuard) | | @Injectable() hook providers in providers | @Service() hook classes in services | | req.session / req.user | @Session(), @Var("user") or c.get("session") | | middleware: (req, res, next) => ... | middleware: async (c, next) => ... (Hono) | | bodyParser, disableBodyParser, enableRawBodyParser | Not needed: Hono doesn't pre-parse bodies, use c.req.raw for the raw body | | GraphQL resolvers and WebSocket gateways | Not supported (Honest has no GraphQL or WebSocket layer) | | Function-based trustedOrigins unsupported | Supported | | hooks: {} required for @Hook() services | Not needed (databaseHooks: {} still is for @DatabaseHook()) |

Example

examples/basic is a small notes API using the plugin, the global guard, @Session() and a sign-up hook.