
Security News
The Code You Didn't Write Is Still Yours to Defend
AI agents are pulling packages into environments no scanner is watching, creating exposure before security teams can see it.
Production-ready authentication and authorization library for the Najm framework. Provides JWT-based authentication, role-based access control (RBAC), permission-based access control (PBAC), and row-level ownership scoping.
Features:
bun add najm-auth
# Peer dependencies
bun add hono drizzle-orm reflect-metadata
// src/database/schema.ts
import { authSchema } from 'najm-auth';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
// Your app tables
export const products = sqliteTable('products', {
id: text('id').primaryKey(),
name: text('name').notNull(),
userId: text('userId').notNull(),
});
// Combined schema (always include authSchema)
export const schema = {
...authSchema, // users, roles, permissions, tokens, rolePermissions
products,
};
// src/database/index.ts
import { drizzle } from 'drizzle-orm/bun-sqlite';
import { Database } from 'bun:sqlite';
import { schema } from './schema';
const sqlite = new Database('./app.db');
export const db = drizzle(sqlite, { schema });
// src/main.ts
import 'reflect-metadata';
import { Server } from 'najm-core';
import { database } from 'najm-database';
import { auth } from 'najm-auth';
import { db } from './database';
const server = new Server()
.use(database({ default: db })) // Required: database must be registered first
.use(auth({
dialect: 'sqlite', // Auto-selects SQLite schema
jwt: {
accessSecret: process.env.JWT_ACCESS_SECRET!, // Required
refreshSecret: process.env.JWT_REFRESH_SECRET!, // Required
accessExpiresIn: '15m', // Optional, default: 1h
refreshExpiresIn: '7d', // Optional, default: 7d
},
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000', // For password reset links
}))
.load(/* your controllers and services */)
.listen(3000);
# .env
JWT_ACCESS_SECRET=<32-character-minimum-secret>
JWT_REFRESH_SECRET=<32-character-minimum-secret>
FRONTEND_URL=https://app.example.com
⚠️ Security: Generate secrets with
openssl rand -base64 32
auth({
// Database
dialect?: 'pg' | 'sqlite' // Default: 'pg' (RETURNING-capable engines only)
schema?: AuthSchema // Override dialect schema
// JWT
jwt?: {
accessSecret: string // Required, min 32 chars
accessExpiresIn?: string // Default: 1h
refreshSecret: string // Required, min 32 chars
refreshExpiresIn?: string // Default: 7d
}
// Cookies
refreshCookieName?: string // Default: 'refreshToken'
// Database
database?: string // Default: 'default'
blacklistPrefix?: string // Default: 'auth:blacklist:'
// Registration
defaultRole?: string | null // Auto-assign role to new users
bcryptRounds?: number // Default: 10 (valid: 4-31)
// Frontend
frontendUrl?: string // Password reset link base URL
// Dependencies (forwarded to plugins)
validation?: ValidationPluginConfig
rateLimit?: RateLimitPluginConfig
})
All routes are prefixed with /auth and auto-registered by the plugin.
| Method | Path | Description | Auth |
|---|---|---|---|
POST | /auth/register | Register new user | None |
POST | /auth/login | Login with email/password | None |
POST | /auth/refresh | Refresh access token (cookie) | None (uses refresh cookie) |
POST | /auth/logout | Logout and revoke tokens | ✅ Required |
GET | /auth/me | Get current user profile | ✅ Required |
POST | /auth/forgot-password | Request password reset | None |
POST | /auth/reset-password | Confirm password reset | None |
@isAdmin())| Method | Path | Description |
|---|---|---|
GET | /users?limit=50&offset=0 | List users (limit 1-100) |
GET | /users/:id | Get user by ID |
POST | /users | Create new user |
PUT | /users/:id | Update user |
DELETE | /users/:id | Delete user |
GET | /roles | List all roles |
GET | /roles/:id | Get role by ID |
POST | /roles | Create new role |
PUT | /roles/:id | Update role |
DELETE | /roles/:id | Delete role |
GET | /permissions | List all permissions |
GET | /permissions/:id | Get permission by ID |
POST | /permissions | Create new permission |
PUT | /permissions/:id | Update permission |
DELETE | /permissions/:id | Delete permission |
POST | /permissions/assign/:roleId/:permissionId | Assign permission to role |
DELETE | /permissions/remove/:roleId/:permissionId | Remove permission from role |
import { isAuth } from 'najm-auth';
@Controller('/api/posts')
class PostController {
@Get('/') // Public
getAll() { }
@Post('/')
@isAuth() // Requires valid JWT
create(@Body() data: any) { }
}
import { defineRoles } from 'najm-auth';
const roles = defineRoles({
ADMIN: 'admin',
MODERATOR: 'moderator',
USER: 'user',
}, {
superRoles: ['ADMIN'], // admin also passes moderator/user role guards
});
export const { isAdmin, isModerator, isUser } = roles;
@Controller('/admin')
@isAdmin() // All methods require admin role
class AdminController {
@Get('/users')
getUsers() { }
}
@Controller('/api/posts')
class PostController {
@Delete('/:id')
@isModerator() // Method-level guard
deletePost() { }
}
import { Can, canRead, canCreate, canUpdate, canDelete } from 'najm-auth';
@Controller('/api/posts')
class PostController {
@Get('/')
@canRead('posts') // Requires 'read:posts' permission
getAll() { }
@Post('/')
@canCreate('posts') // Requires 'create:posts' permission
create(@Body() data: any) { }
@Put('/:id')
@canUpdate('posts') // Requires 'update:posts' permission
update() { }
@Delete('/:id')
@canDelete('posts') // Requires 'delete:posts' permission
delete() { }
@Post('/:id/publish')
@Can('publish:posts') // Custom permission
publish() { }
}
Permission Wildcards:
*:* — All actions on all resourcescreate:* — Create action on any resource*:posts — Any action on posts@Controller('/admin/reports')
@isAdmin() // Require admin role
class ReportController {
@Get('/financial')
@Can('view:financial') // AND require financial view permission
getFinancial() { }
}
Control row-level access based on ownership (e.g., users see only their own data).
import { own, join, where } from 'najm-auth';
import { schema } from '../database/schema';
const { products, users } = schema;
const _users = alias(users, '_u');
export const Product = own(products)
.for('user',
join(products.userId, _users.id),
where(_users.id)
)
.writeBy(products.userId); // Enforce on create/update
import { configureOwnership, Policy, CanList, CanRead, CanCreate, CanUpdate, CanDelete } from 'najm-auth';
const config = configureOwnership({
adminRoles: ['admin'],
rules: {
'user': {
'products': Product.getRules()['user']
}
}
});
@Policy(Product)
@Controller('/api/products')
export class ProductController {
@Get('/')
@CanList() // List only owned products
getAll(@GuardParams() filter: any) { }
@Get('/:id')
@CanRead() // Read only if owner
getOne() { }
@Post('/')
@CanCreate() // Create (ownership assigned automatically)
create(@Body() data: any) { }
@Put('/:id')
@CanUpdate() // Update only if owner
update(@Body() data: any) { }
@Delete('/:id')
@CanDelete() // Delete only if owner
delete() { }
}
@Repository('default')
@Owned(Product)
export class ProductRepository {
@DB() db!: Database;
// Auto-scoped to current user
async findMany(opts?: { where?: any; limit?: number }) {
return this.findMany(opts); // Only returns owned products
}
async findOne(opts: { where: any }) {
return this.findOne(opts); // Returns null if not owned
}
async scopedQuery() {
return this.scopedQuery(); // Raw scoped query builder
}
}
const Grade = own(grades)
// Teachers see students' grades
.for('teacher',
join(grades.studentId, _s.id),
join(_s.id, _t.studentId),
where(_t.userId)
)
// Parents see only their child's grades
.for('parent',
join(grades.studentId, _s.id),
join(_s.id, _p.studentId),
where(_p.userId)
);
users
├── id (string, primary key)
├── email (string, unique)
├── password (string, hashed)
├── emailVerified (boolean, default: false)
├── image (string, nullable)
├── status (enum: ACTIVE, INACTIVE)
├── roleId (string, FK → roles.id)
├── lastLogin (timestamp, nullable)
├── createdAt (timestamp)
└── updatedAt (timestamp)
roles
├── id (string, primary key)
├── name (string, unique)
├── description (string, nullable)
├── createdAt (timestamp)
└── updatedAt (timestamp)
permissions
├── id (string, primary key)
├── name (string, unique)
├── description (string, nullable)
├── resource (string)
├── action (string)
├── createdAt (timestamp)
└── updatedAt (timestamp)
tokens
├── id (string, primary key)
├── userId (string, FK → users.id, unique)
├── token (string, hashed)
├── type (enum: REFRESH, RESET)
├── status (enum: ACTIVE, REVOKED)
├── expiresAt (timestamp)
├── createdAt (timestamp)
└── updatedAt (timestamp)
role_permissions
├── id (string, primary key)
├── roleId (string, FK → roles.id)
├── permissionId (string, FK → permissions.id)
├── createdAt (timestamp)
└── updatedAt (timestamp)
Uses nanoid with short lengths for efficient storage:
To use UUIDs instead, customize the schema:
import { customAlphabet } from 'nanoid';
import { uuid } from 'uuid';
// Use UUID for larger ID space
const customUsers = sqliteTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
// ...
});
import { authSeed } from 'najm-auth';
import { SeedService } from 'najm-database';
@Service()
class SetupService {
constructor(private seeder: SeedService) {}
async seed() {
const entries = authSeed({
adminEmail: 'admin@app.com',
adminPass: 'AdminPass123!',
roles: [
{ name: 'editor', description: 'Can edit content' },
{ name: 'viewer', description: 'Can view only' },
],
permissions: [
{ name: 'read:posts', resource: 'posts', action: 'read' },
{ name: 'create:posts', resource: 'posts', action: 'create' },
],
additionalUsers: [
{ email: 'user@app.com', password: 'User123!', roleName: 'viewer' },
]
});
await this.seeder.run(entries);
}
}
import { seedAuthData } from 'najm-auth';
await seedAuthData({
db,
adminEmail: process.env.ADMIN_EMAIL!,
adminPassword: process.env.ADMIN_PASSWORD!,
roles: [
{ name: 'moderator', description: 'Content moderator' },
],
users: [
{ email: 'mod@app.com', password: 'Mod123!' , roleName: 'moderator' },
],
verbose: true
});
// Note: Return type has empty users[] and roles[] arrays
// Query the database directly to retrieve inserted records
Auth routes have built-in rate limiting to prevent brute force attacks.
| Route | Limit | Window | Key Strategy |
|---|---|---|---|
POST /auth/register | 5 | 15 minutes | IP |
POST /auth/login | 5 | 15 minutes | IP |
POST /auth/refresh | 15 | 15 minutes | Cookie fingerprint |
POST /auth/logout | 10 | 15 minutes | User ID |
GET /auth/me | 30 | 1 minute | User ID |
POST /auth/forgot-password | 3 | 15 minutes | IP |
POST /auth/reset-password | 5 | 15 minutes | IP |
auth({
rateLimit: {
keyGenerator: 'ip', // or 'user', 'api-key', 'user+ip'
defaultWindow: '10m',
skip: (ctx) => ctx.path === '/health' // Skip for certain routes
}
})
import type {
AuthUser, // { id, email, name?, role?, permissions? }
TokenPair, // { accessToken, refreshToken, expiresAt? }
JwtPayload, // { userId, jti, exp?, iat? }
AuthConfig, // Full resolved config
AuthPluginConfig, // User-facing config
} from 'najm-auth';
All errors are i18n-based. Error messages are automatically localized.
| HTTP | Scenario |
|---|---|
| 400 | Invalid input (bad email format, weak password) |
| 401 | Missing or invalid authentication (bad token, no header) |
| 403 | Forbidden (lacks required role/permission) |
| 409 | Conflict (email already registered) |
| 429 | Rate limited (too many requests) |
| 500 | Server error (email send failure, DB error) |
// Invalid credentials
throw new HttpError(401, 'Invalid email or password');
// User already exists
throw new HttpError(409, 'Email already registered');
// Insufficient permissions
throw new HttpError(403, 'Insufficient permissions for this action');
⚠️ Current behavior: Reset tokens use JWT expiry (default 1h) for single-use validation. To add database-backed single-use tokens:
// In AuthService.resetPassword():
async resetPassword(token: string, newPassword: string) {
const userId = this.tokenService.verifyResetToken(token);
// ... update password ...
// Blacklist the reset token to prevent reuse
await this.tokenService.blacklistCurrentToken(token);
}
tokenFamily), so a user can stay logged in on several devices at once. Logout and rotation are scoped to the current session; password change/reset revoke every session@RateLimit on logout for DDoS protectioncache() plugin configurationbun run test # Run all tests
bun run test:auth # Run auth tests only
Test files include:
schema.test.ts — Schema exports validationauth.test.ts — Authentication flowuser.test.ts — User CRUDrole.test.ts — Role managementpermission.test.ts — Permission guardsguards.test.ts — Guard composabilityownership.test.ts — Row-level scopingintegration.test.ts — Multi-role scenariosopenssl rand -base64 32)FRONTEND_URL environment variableFRONTEND_URL now part of AuthPluginConfig (falls back to env var)/auth/logout and /auth/meconfigureOwnership() for advanced scoping@Policy and @Owned decoratorsFor issues, feature requests, or contributions, please refer to the main Najm repository: https://github.com/najm/najm-api
FAQs
Authentication and authorization library for najm framework
The npm package najm-auth receives a total of 210 weekly downloads. As such, najm-auth popularity was classified as not popular.
We found that najm-auth demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
AI agents are pulling packages into environments no scanner is watching, creating exposure before security teams can see it.

Security News
GitHub Actions checkout now blocks risky pull_request_target checkouts by default to help prevent pwn request supply chain attacks.

Product
Socket now supports Custom Roles and Repository Access Permissions so organizations can control who can access specific repositories and actions.