Authentication System
Arkos provides a comprehensive JWT-based authentication system with Role-Based Access Control (RBAC). This guide covers the complete setup and usage, starting with Static RBAC and showing how to upgrade to Dynamic RBAC when needed.
JWT Configuration & Environment Setup
First, configure authentication in your Arkos application:
- v1.4.0+ (Recommended)
- v1.3.0 and earlier
// arkos.config.ts
import { ArkosConfig } from "arkos";
const arkosConfig: ArkosConfig = {
authentication: {
mode: "static", // Start with static, upgrade to dynamic later if needed
login: {
sendAccessTokenThrough: "both", // Options: "cookie-only", "response-only", "both"
allowedUsernames: ["username"], // Or ["email"], ["username", "email"], etc.
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || "30d",
cookie: {
secure: process.env.JWT_COOKIE_SECURE === "true",
httpOnly: process.env.JWT_COOKIE_HTTP_ONLY !== "false",
sameSite:
(process.env.JWT_COOKIE_SAME_SITE as "lax" | "strict" | "none") ||
undefined,
},
},
},
};
export default arkosConfig;
// src/app.ts
import arkos from "arkos";
arkos.init({
authentication: {
mode: "static",
login: {
sendAccessTokenThrough: "both",
allowedUsernames: ["username"],
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || "30d",
cookie: {
secure: process.env.JWT_COOKIE_SECURE === "true",
httpOnly: process.env.JWT_COOKIE_HTTP_ONLY !== "false",
sameSite:
(process.env.JWT_COOKIE_SAME_SITE as "lax" | "strict" | "none") ||
undefined,
},
},
},
});
JWT Configuration Options
You can configure JWT settings through environment variables (recommended) or directly in code:
Environment Variables (Recommended):
JWT_SECRET=your-super-secret-jwt-key-here
JWT_EXPIRES_IN=30d
JWT_COOKIE_SECURE=true
JWT_COOKIE_HTTP_ONLY=true
JWT_COOKIE_SAME_SITE=none
JWT Configuration Reference
| Option | Description | Env Variable | Default |
|---|---|---|---|
secret | Key used to sign and verify JWT tokens (required in production) | JWT_SECRET | — |
expiresIn | Token validity duration (e.g., '30d', '1h') | JWT_EXPIRES_IN | '30d' |
cookie.secure | Only send cookie over HTTPS | JWT_COOKIE_SECURE | true in production |
cookie.httpOnly | Prevent JavaScript access to cookie | JWT_COOKIE_HTTP_ONLY | true |
cookie.sameSite | SameSite cookie attribute | JWT_COOKIE_SAME_SITE | "lax" in dev, "none" in prod |
Always set a strong JWT_SECRET in production. Arkos will throw an error on login attempts when no JWT Secret is set in production. Never commit secrets to version control.
User Model Setup - Static RBAC Foundation
To use Arkos authentication, you must define a User model with specific required fields:
// Define your roles (enum for most databases, string for SQLite)
enum UserRole {
Admin
Editor
User
}
model User {
// Required authentication fields
id String @id @default(uuid())
username String @unique
password String
passwordChangedAt DateTime?
lastLoginAt DateTime?
isSuperUser Boolean @default(false)
isStaff Boolean @default(false)
deletedSelfAccountAt DateTime?
isActive Boolean @default(true)
// Role assignment (choose one approach)
role UserRole @default(User) // Single role
// OR
// roles UserRole[] // Multiple roles
// example of custom fields
email String? @unique
firstName String?
lastName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Understanding User Model Fields
Required Authentication Fields:
id: Unique identifier (usually UUID)username: Primary login identifier (can be customized to useemail/phone)password: Automatically hashed with bcryptpasswordChangedAt: Invalidates JWT tokens after password changeslastLoginAt: Tracks user's most recent loginisSuperUser: Grants full system access regardless of rolesisStaff: Frontend-only flag for admin area accessdeletedSelfAccountAt: Soft deletion timestampisActive: Controls whether user can access the system
Role Assignment:
- Use
rolefor single role per user or - Use
rolesfor multiple roles per user - Both approaches work with Static RBAC
Customizing Login Field
By default, users login with username. You can customize this:
- v1.4.0+ (Recommended)
- v1.3.0 and earlier
// arkos.config.ts
const arkosConfig: ArkosConfig = {
authentication: {
mode: "static",
login: {
allowedUsernames: ["email"], // Use email field for login
// allowedUsernames: ["username", "email"], // Allow both
},
},
};
// src/app.ts
arkos.init({
authentication: {
mode: "static",
login: {
allowedUsernames: ["email"],
},
},
});
Create at least one user with isSuperUser: true before activating authentication. By default, Arkos requires authentication for all endpoints and only allows super users until you configure access controls.
Login With Different Fields
After customizing your allowedUsernames, Arkos will use the first element in the array as the default when users login via /api/auth/login. To change this behavior, consider this example:
{
allowedUsernames: [
"email",
"username",
"profile.nickname",
"phones.some.number",
];
}
Login With Email
POST /api/auth/login
{
"email": "cacilda@arkosjs.com",
"password": "SomeCoolStrongPass123"
}
This works because email is the first element in allowedUsernames. To explicitly specify the username field:
POST /api/auth/login?usernameField=email
{
"email": "cacilda@arkosjs.com",
"password": "SomeCoolStrongPass123"
}
Login With a Different Field (username)
POST /api/auth/login?usernameField=username
{
"username": "cacilda",
"password": "SomeCoolStrongPass123"
}
When using a non-default field (not the first element in allowedUsernames), you must explicitly pass it as a query parameter usernameField=username.
Login With Relation Fields (Nested Fields)
Arkos supports logging in with nested fields using dot notation:
POST /api/auth/login?usernameField=profile.nickname
{
"nickname": "cacilda_cool",
"password": "SomeCoolStrongPass123"
}
Login With Deeply Nested Field (phones.some.number)
POST /api/auth/login?usernameField=phones.some.number
{
"number": "+5511999999999",
"password": "SomeCoolStrongPass123"
}
How Logging In With Relation Fields Works
The dot notation (phones.some.number) is converted into a Prisma where clause:
prisma.user.findUnique({
where: {
phones: {
some: {
number: "+5511999999999",
},
},
},
});
This allows authentication based on fields within related models, providing flexible login options.
Important Considerations When Logging In With Different Fields
-
Uniqueness Requirement: All fields specified in
allowedUsernamesmust be unique across your user records. If multiple users share the same value for any of these fields, Arkos will throw an error during server startup. -
Explicit Field Specification: When using any field that is not the first in your
allowedUsernamesarray, you must explicitly specify it using theusernameFieldquery parameter. -
Data Structure: The request body only requires the actual field value, not the nested structure. For
profile.nickname, you simply provide{"nickname": "value"}, not{profile: {nickname: "value"}}. Similarly, forphones.some.number, you provide{"number": "+5511999999999"}. Arkos automatically maps the field name to the appropriate nested query internally.
Handling Permissions
Auth Config Files - Static RBAC
Each model can have its own authentication configuration file that defines which actions require authentication and which roles can perform them.
Creating Auth Config Files
Generate auth configuration files using the CLI:
npx arkos generate auth-configs --module post
Shorthand:
npx arkos g a -m post
This creates src/modules/post/post.auth.ts:
import { AuthConfigs } from "arkos/auth";
export const postAuthConfigs: AuthConfigs = {
authenticationControl: {
View: false, // Public access - no authentication required
Create: true, // Authentication required
Update: true, // Authentication required (default)
Delete: true, // Authentication required (default)
// Custom actions
Export: true,
BulkApprove: true,
},
accessControl: {
// Simple format (description auto-generated)
Create: ["Editor", "Admin"],
Update: ["Editor", "Admin"],
Delete: ["Admin"],
// Detailed format with custom descriptions
Export: {
roles: ["Admin", "Analyst"],
name: "Export Posts",
description: "Allows exporting posts to various formats",
},
BulkApprove: {
roles: ["Admin", "Moderator"],
name: "Bulk Approve Posts",
description: "Allows approving multiple posts at once",
},
},
};
Auth Config Structure
authenticationControl: Determines if authentication is required
false: Public access (no authentication needed)true: Requires authentication (default for Create/Update/Delete)
accessControl: Defines which roles can perform actions
- Simple format:
["Role1", "Role2"](description auto-generated) - Detailed format:
{ roles: [...], name: "...", description: "..." }
Adding Authentication in Custom Routers
- v1.4.0+ (Recommended)
- v1.3.0 and earlier
ArkosRouter provides declarative authentication configuration. You can add authentication inline or reference auth configs for consistency.
Simple Authentication (Inline)
// src/routers/reports.router.ts
import { ArkosRouter } from "arkos";
import reportsController from "../controllers/reports.controller";
const router = ArkosRouter();
// Basic authentication - just requires user to be logged in
router.get(
{
path: "/api/reports/dashboard",
authentication: true,
},
reportsController.getDashboard
);
// With role-based access control (inline)
router.post(
{
path: "/api/reports/generate",
authentication: {
resource: "report",
action: "Generate",
rule: { roles: ["Admin", "Manager"] }, // Inline roles
},
},
reportsController.generateReport
);
export default router;
Recommended: Reference Auth Configs
For consistency with fine-grained access control, reference your auth configs:
// src/modules/report/report.auth.ts
import { AuthConfigs } from "arkos/auth";
export const reportAuthConfigs: AuthConfigs = {
authenticationControl: {
Generate: true,
Export: true,
View: false,
},
accessControl: {
Generate: {
roles: ["Admin", "Manager"],
name: "Generate Reports",
description: "Create new reports with custom parameters",
},
Export: {
roles: ["Admin", "Analyst"],
name: "Export Reports",
description: "Export reports in various formats",
},
},
};
export default reportAuthConfigs;
// src/routers/reports.router.ts
import { ArkosRouter } from "arkos";
import { reportAuthConfigs } from "../modules/report/report.auth";
import reportsController from "../controllers/reports.controller";
const router = ArkosRouter();
// Reference auth config for consistency
router.post(
{
path: "/api/reports/generate",
authentication: {
resource: "report",
action: "Generate",
rule: reportAuthConfigs.accessControl.Generate, // Reference auth config
},
},
reportsController.generateReport
);
router.post(
{
path: "/api/reports/export",
authentication: {
resource: "report",
action: "Export",
rule: reportAuthConfigs.accessControl.Export,
},
},
reportsController.exportReport
);
export default router;
Referencing auth configs instead of inline rules provides:
- Consistency with Fine-Grained Access Control
- Single source of truth for permissions
- Easier maintenance and updates
// src/routers/reports.router.ts
import { Router } from "express";
import { authService } from "arkos/services";
import reportsController from "../controllers/reports.controller";
const router = Router();
const authConfigs = {
accessControl: {
Generate: ["Admin", "Manager"],
},
};
router.get(
"/api/reports/dashboard",
authService.authenticate,
reportsController.getDashboard
);
router.post(
"/api/reports/generate",
authService.authenticate,
authService.handleAccessControl(
"Generate",
"report",
authConfigs.accessControl
),
reportsController.generateReport
);
export default router;
Customizing Authentication for Auto-Generated Endpoints
- v1.4.0+ (Recommended)
- v1.3.0 and earlier
Control authentication for auto-generated endpoints using RouterConfig - this is the new recommended approach:
// src/modules/post/post.router.ts
import { ArkosRouter, RouterConfig } from "arkos";
import postAuthConfigs from "./post.auth";
export const config: RouterConfig = {
// Make findMany public (no authentication required)
findMany: {
authentication: false,
},
// Require authentication for createOne
createOne: {
authentication: {
resource: "post",
action: "Create",
rule: postAuthConfigs.accessControl.Create, // Reference auth config
},
},
// Require authentication for updateOne
updateOne: {
authentication: {
resource: "post",
action: "Update",
rule: postAuthConfigs.accessControl.Update,
},
},
// Strict admin-only for deleteOne
deleteOne: {
authentication: {
resource: "post",
action: "Delete",
rule: postAuthConfigs.accessControl.Delete,
},
},
};
const router = ArkosRouter();
export default router;
Using RouterConfig for authentication control is preferred over defining authenticationControl in .auth.ts files. It provides:
- Explicit, visible configuration in one place
- Easier to override default behaviors
- Consistent with other RouterConfig features
Auth config files remain important for:
- Defining
accessControlrules (single source of truth) - Documentation via
/api/auth-actionsendpoint - Fine-grained access control references
Authentication control was handled entirely through .auth.ts files:
// src/modules/post/post.auth.ts
export const postAuthConfigs: AuthConfigs = {
authenticationControl: {
View: false, // Public
Create: true,
Update: true,
Delete: true,
},
accessControl: {
Create: ["Editor", "Admin"],
Update: ["Editor", "Admin"],
Delete: ["Admin"],
},
};
Using Custom Actions in Routes
Custom actions defined in auth configs can be used in custom routes:
- v1.4.0+ (Recommended)
- v1.3.0 and earlier
// src/modules/post/post.router.ts
import { ArkosRouter } from "arkos";
import { RouterConfig } from "arkos";
import postAuthConfigs from "./post.auth";
import postController from "./post.controller";
export const config: RouterConfig = {
// Configure built-in endpoints...
};
const router = ArkosRouter();
// Custom export endpoint
router.get(
{
path: "/export",
authentication: {
resource: "post",
action: "Export",
rule: postAuthConfigs.accessControl.Export,
},
},
postController.exportPosts
);
// Custom bulk approve endpoint
router.post(
{
path: "/bulk-approve",
authentication: {
resource: "post",
action: "BulkApprove",
rule: postAuthConfigs.accessControl.BulkApprove,
},
},
postController.bulkApprove
);
export default router;
// src/modules/post/post.router.ts
import { Router } from "express";
import { authService } from "arkos/services";
import { catchAsync } from "arkos/error-handler";
import postAuthConfigs from "./post.auth";
import postController from "./post.controller";
const router = Router();
const { accessControl } = postAuthConfigs;
router.get(
"/export",
authService.authenticate,
authService.handleAccessControl("Export", "post", accessControl.Export),
catchAsync(postController.exportPosts)
);
export default router;
Authentication System Flow
Understanding how authentication works in Arkos helps you implement custom logic and troubleshoot issues.
1. User Authentication Process
Client Login Request
↓
Credential Verification (/api/auth/login)
↓
JWT Token Generation
↓
Token Delivery (response/cookie/both)
↓
Client Stores Token
↓
Subsequent Requests Include Token
2. Request Authorization Flow
Incoming Request
↓
Token Extraction (header/cookie)
↓
Token Verification & Validation
↓
User Loading from Database
↓
Role-Based Access Control Check
↓
Request Processing (if authorized)
3. Core Components
Auth Service (authService):
- JWT token management (sign, verify)
- Password operations (hash, compare)
- User authentication verification
- Access control enforcement
Auth Controller:
/api/auth/login- User authentication/api/auth/signup- User registration/api/auth/logout- Session termination/api/users/me- User profile management/api/auth/update-password- Password changes
Authentication Middleware:
- Intercepts requests to protected routes
- Verifies JWT tokens
- Loads user information
- Enforces role-based permissions
Sending Authentication Requests
User Registration
const response = await fetch("/api/auth/signup", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: "john_doe",
password: "SecurePassword123!",
email: "john@example.com",
firstName: "John",
lastName: "Doe",
}),
});
const result = await response.json();
User Login
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include", // Important for cookie-based auth
body: JSON.stringify({
username: "john_doe", // or email if configured
password: "SecurePassword123!",
}),
});
const result = await response.json();
Depending on your sendAccessTokenThrough configuration, the token will be set as a cookie, attached to the JSON response, or both (default behavior).
Accessing Protected Endpoints
With Token in Authorization Header:
const response = await fetch("/api/posts", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
With Cookie-based Authentication:
const response = await fetch("/api/posts", {
method: "GET",
credentials: "include", // Automatically includes cookies
headers: {
"Content-Type": "application/json",
},
});
User Profile Management
// Get current user profile
const profile = await fetch("/api/users/me", {
credentials: "include",
}).then((r) => r.json());
// Update user profile
const updated = await fetch("/api/users/me", {
method: "PATCH",
credentials: "include",
body: JSON.stringify({
firstName: "John Updated",
email: "newemail@example.com",
}),
}).then((res) => res.json());
// Change password
const passwordUpdate = await fetch("/api/auth/update-password", {
method: "POST",
credentials: "include",
body: JSON.stringify({
currentPassword: "oldPassword",
newPassword: "newSecurePassword123!",
}),
}).then((r) => r.json());
Logout
const response = await fetch("/api/auth/logout", {
method: "DELETE",
credentials: "include",
});
// Clears authentication cookies and invalidates session
Frontend Integration
Auth Actions Discovery
The /api/auth-actions endpoint helps frontend developers discover available permissions:
const authActions = await fetch("/api/auth-actions", {
credentials: "include",
}).then((res) => res.json());
// Returns array of available actions:
// [
// {
// roles: ["Admin", "Editor"], // Only in Static mode
// action: "Create",
// resource: "post",
// name: "Create Posts",
// description: "Allows creating new blog posts"
// },
// // ... more actions
// ]
Data Source:
- Static Mode: Data comes from auth config files (including
roles) - Dynamic Mode: Data comes from database records (no
rolesfield)
Exporting Auth Actions for Frontend
- v1.4.0+
- v1.3.0 and earlier
You can export auth actions to a file for static typing and easier frontend integration:
# Export auth actions (merges with existing file by default)
npx arkos export auth-action
# Overwrite existing file instead of merging
npx arkos export auth-action --overwrite
# Export to custom path
npx arkos export auth-action --path src/constants
This generates auth-actions.ts (or .js) with all your application's permissions:
// src/modules/auth/utils/auth-actions.ts (default location)
const authActions = [
{
resource: "post",
action: "Create",
roles: ["Editor", "Admin"],
name: "Create Posts",
description: "Allows creating new blog posts",
},
{
resource: "post",
action: "Update",
roles: ["Editor", "Admin"],
name: "Update Posts",
description: "Allows updating existing blog posts",
},
// ... all your auth actions
];
export default authActions;
Merge Behavior:
By default, export auth-action merges new actions with existing ones, preserving any customizations you've made (like translations or custom descriptions). Use --overwrite to completely replace the file.
Use Cases:
- Static Auth: Map permissions to UI elements, hide/show features based on roles
- Dynamic Auth: Add i18n translations to action names/descriptions
- TypeScript: Generate types for compile-time permission checks
- Documentation: Reference available permissions in your frontend code
// Example frontend usage
import authActions from './auth-actions';
function canUserPerformAction(userRole: string, resource: string, action: string) {
const permission = authActions.find(
a => a.resource === resource && a.action === action
);
return permission?.roles.includes(userRole);
}
// In a React component
if (canUserPerformAction(user.role, 'post', 'Delete')) {
return <DeleteButton />;
}
The export auth-action command is not available in v1.3. You must manually fetch from /api/auth-actions or copy permissions from your auth config files.
Upgrading To Dynamic RBAC
When your application grows and needs flexible, runtime-configurable permissions, you can upgrade from Static to Dynamic RBAC.
What Changes
User Model Updates:
model User {
// Keep all existing fields from Static RBAC
id String @id @default(uuid())
username String @unique
password String
passwordChangedAt DateTime?
lastLoginAt DateTime?
isSuperUser Boolean @default(false)
isStaff Boolean @default(false)
deletedSelfAccountAt DateTime?
isActive Boolean @default(true)
// CHANGE: Replace role/roles enum with UserRole relationships
roles UserRole[] // Connect to UserRole junction table
// Keep your custom fields
email String? @unique
firstName String?
lastName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Add Required Models:
model AuthRole {
id String @id @default(uuid())
name String @unique
permissions AuthPermission[]
users UserRole[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum AuthPermissionAction {
View
Create
Update
Delete
// Add custom actions as needed
Export
BulkApprove
}
model AuthPermission {
id String @id @default(uuid())
resource String // e.g., "post", "user-profile"
action AuthPermissionAction // PascalCase: "Create", "View", etc.
roleId String
role AuthRole @relation(fields: [roleId], references: [id])
@@unique([resource, action, roleId])
}
model UserRole {
id String @id @default(uuid())
userId String
roleId String
user User @relation(fields: [userId], references: [id])
role AuthRole @relation(fields: [roleId], references: [id])
@@unique([userId, roleId])
}
Update Application Configuration
- v1.4.0+ (Recommended)
- v1.3.0 and earlier
// arkos.config.ts
const arkosConfig: ArkosConfig = {
authentication: {
mode: "dynamic", // Change from "static" to "dynamic"
// Keep all other JWT settings the same
},
};
// src/app.ts
arkos.init({
authentication: {
mode: "dynamic",
// Keep all other settings
},
});
Auth Config Files in Dynamic Mode
Auth config files serve a different purpose in Dynamic mode:
// src/modules/post/post.auth.ts
export const postAuthConfigs: AuthConfigs = {
authenticationControl: {
View: false, // Still controls authentication requirements
Create: true,
Export: true,
},
accessControl: {
// In Dynamic mode: used ONLY for documentation
// roles field is ignored - actual control comes from database
Create: {
// roles: ["Admin"], // This field is ignored in Dynamic mode
name: "Create Posts",
description: "Allows creating new blog posts",
},
Export: {
name: "Export Posts",
description: "Allows exporting posts in various formats",
},
},
};
Key Differences in Dynamic Mode:
accessControl.SomeAction.rolesfield is ignored- Actual access control comes from
AuthPermissionrecords in database - Auth configs provide documentation for the
/api/auth-actionsendpoint authenticationControlstill works the same way
Database-Based Permission Management
In Dynamic mode, you manage permissions through database records:
// Example: Creating roles and permissions programmatically
const adminRole = await prisma.authRole.create({
data: { name: "Admin" },
});
const editorRole = await prisma.authRole.create({
data: { name: "Editor" },
});
// Grant permissions to roles
await prisma.authPermission.createMany({
data: [
{ resource: "post", action: "Create", roleId: editorRole.id },
{ resource: "post", action: "Update", roleId: editorRole.id },
{ resource: "post", action: "View", roleId: editorRole.id },
{ resource: "post", action: "Delete", roleId: adminRole.id },
{ resource: "post", action: "Export", roleId: adminRole.id },
],
});
// Assign roles to users
await prisma.userRole.create({
data: {
userId: user.id,
roleId: adminRole.id,
},
});
Available RBAC Management Endpoints
Dynamic RBAC provides built-in endpoints for managing roles and permissions:
// Role management
GET /api/auth-roles // List all roles
POST /api/auth-roles // Create new role
GET /api/auth-roles/:id // Get specific role
PATCH /api/auth-roles/:id // Update role
DELETE /api/auth-roles/:id // Delete role
// Permission management
GET /api/auth-permissions // List all permissions
POST /api/auth-permissions // Create new permission
GET /api/auth-permissions/:id // Get specific permission
PATCH /api/auth-permissions/:id // Update permission
DELETE /api/auth-permissions/:id // Delete permission
// User-Role assignment
GET /api/user-roles // List user-role assignments
POST /api/user-roles // Assign role to user
DELETE /api/user-roles/:id // Remove role from user
Migration Considerations
When upgrading from Static to Dynamic RBAC:
- Backup your database before making schema changes
- Migrate existing user roles to the new UserRole system
- Create AuthRole records for your existing enum values
- Create AuthPermission records based on your auth config files
- Update auth config files to remove functional
rolesfields - Test thoroughly as access control logic changes completely
On next versions there is a plan on adding a migration script into the cli in order to easy authentication mode migrations.
Fine-Grained Access Control
Beyond endpoint-level protection, use authService.permission for granular access control within your application logic. This works in both Static and Dynamic modes, you can read more at Fine-Grained Access Control Guide.
Next Steps
- Learn about Authentication Interceptor Middlewares for customizing authentication behavior
- Explore Authentication Data Validation for request validation
- Check out Fine-Grained Access Control for advanced permissions