Skip to main content

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:

// 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;

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

OptionDescriptionEnv VariableDefault
secretKey used to sign and verify JWT tokens (required in production)JWT_SECRET
expiresInToken validity duration (e.g., '30d', '1h')JWT_EXPIRES_IN'30d'
cookie.secureOnly send cookie over HTTPSJWT_COOKIE_SECUREtrue in production
cookie.httpOnlyPrevent JavaScript access to cookieJWT_COOKIE_HTTP_ONLYtrue
cookie.sameSiteSameSite cookie attributeJWT_COOKIE_SAME_SITE"lax" in dev, "none" in prod
Production Security

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 use email/phone)
  • password: Automatically hashed with bcrypt
  • passwordChangedAt: Invalidates JWT tokens after password changes
  • lastLoginAt: Tracks user's most recent login
  • isSuperUser: Grants full system access regardless of roles
  • isStaff: Frontend-only flag for admin area access
  • deletedSelfAccountAt: Soft deletion timestamp
  • isActive: Controls whether user can access the system

Role Assignment:

  • Use role for single role per user or
  • Use roles for multiple roles per user
  • Both approaches work with Static RBAC

Customizing Login Field

By default, users login with username. You can customize this:

// arkos.config.ts
const arkosConfig: ArkosConfig = {
authentication: {
mode: "static",
login: {
allowedUsernames: ["email"], // Use email field for login
// allowedUsernames: ["username", "email"], // Allow both
},
},
};
Required Setup

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

  1. Uniqueness Requirement: All fields specified in allowedUsernames must 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.

  2. Explicit Field Specification: When using any field that is not the first in your allowedUsernames array, you must explicitly specify it using the usernameField query parameter.

  3. 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, for phones.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

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;

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;
Why Reference Auth Configs?

Referencing auth configs instead of inline rules provides:

Customizing Authentication for Auto-Generated Endpoints

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;
Recommended Approach

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 accessControl rules (single source of truth)
  • Documentation via /api/auth-actions endpoint
  • Fine-grained access control references

Using Custom Actions in Routes

Custom actions defined in auth configs can be used in custom routes:

// 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;

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 roles field)

Exporting Auth Actions for Frontend

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 />;
}

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

// arkos.config.ts
const arkosConfig: ArkosConfig = {
authentication: {
mode: "dynamic", // Change from "static" to "dynamic"
// Keep all other JWT settings the same
},
};

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.roles field is ignored
  • Actual access control comes from AuthPermission records in database
  • Auth configs provide documentation for the /api/auth-actions endpoint
  • authenticationControl still 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:

  1. Backup your database before making schema changes
  2. Migrate existing user roles to the new UserRole system
  3. Create AuthRole records for your existing enum values
  4. Create AuthPermission records based on your auth config files
  5. Update auth config files to remove functional roles fields
  6. Test thoroughly as access control logic changes completely
INFO

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