Skip to main content

Authentication & Authorization

Radish CLI generates a complete authentication and authorization system with role-based permissions, API key support, and framework integration.

Overview

The generated system provides:

  • Authentication - User login, registration, password management
  • Authorization - Role-based permissions with fine-grained control
  • API Keys - Programmatic access with scoped permissions
  • Session Management - Signed session cookies (HMAC-SHA256) with httpOnly, Secure flags
  • Framework Integration - Ready-to-use helpers for SvelteKit and Fastify
  • Setup Wizard - First-run admin creation at /setup with tenant auto-detection

Core Concepts

Authentication vs Authorization

  • Authentication - Who is the user? (login, sessions, API keys)
  • Authorization - What can they do? (roles, permissions, ownership)

Permission Format

Permissions follow the pattern: entity:action[:scope]

Basic Permissions:

user:view:all     - View all users
user:view:own - View own user record
concept:create - Create concepts
system:admin - Full system access (wildcard)

Field-Conditional Permissions:

Fine-grained access control based on field values:

comment:view:private:true:all   - View private comments (all users)
post:edit:status:draft:own - Edit draft posts (own only)
blog:view:visibility:team:all - View team-visible blogs

Format: entity:action:field:value[:scope]

Role Architecture

Builtin Roles - Fast, no DB queries, defined in code:

  • USER - Default user role with own-scoped permissions
  • ADMIN - Full system access
  • API - API key role

Custom Roles - Flexible, defined in roles blueprint:

  • Stored in database
  • Cached for performance
  • Defined in {app}.roles.json

Generated Endpoints

Authentication

Register User

POST /api/v1/auth/register
Content-Type: application/json

{
"email": "user@example.com",
"password": "securepassword",
"displayName": "John Doe"
}

Response:

{
"user": {
"id": "...",
"email": "user@example.com",
"displayName": "John Doe"
},
"token": "eyJhbGciOi..."
}

Login

Password Login:

POST /api/v1/auth/login
Content-Type: application/json

{
"authType": "password",
"email": "user@example.com",
"password": "securepassword"
}

LDAP Login:

POST /api/v1/auth/login
Content-Type: application/json

{
"authType": "ldap",
"email": "user@company.com",
"password": "ldappassword",
"ldapConfig": {
"url": "ldap://company.com:389",
"baseDN": "ou=users,dc=company,dc=com",
"userFilter": "(uid={username})"
}
}

Security: LDAP filter values are automatically escaped per RFC 4515 to prevent LDAP injection attacks. Special characters in usernames (*, (, ), \, NUL) are hex-encoded before interpolation into the filter template.

LDAP configuration sources (priority order):

  1. Request body ldapConfig parameter
  2. Database settings (key: 'ldap')
  3. Environment variables (LDAP_URL, LDAP_BASE_DN, etc.)

Logout

POST /api/v1/auth/logout
Authorization: Bearer <token>

Change Password

POST /api/v1/auth/change-password
Authorization: Bearer <token>
Content-Type: application/json

{
"currentPassword": "oldpass",
"newPassword": "newpass"
}

User Management

Get Current User

GET /api/v1/auth/me
Authorization: Bearer <token>

List Users (Admin)

GET /api/v1/users?limit=20&cursor=...
Authorization: Bearer <admin-token>

Update User

PUT /api/v1/users/:id
Authorization: Bearer <token>
Content-Type: application/json

{
"displayName": "Updated Name"
}

Role Management

List All Roles

GET /api/v1/roles
Authorization: Bearer <admin-token>

Response:

{
"items": [
{
"id": "builtin-admin",
"key": "ADMIN",
"label": "Administrator",
"description": "Built-in administrator with all permissions",
"permissions": ["system:admin"],
"isSystem": true,
"isBuiltin": true
},
{
"id": "role-12345",
"key": "MODERATOR",
"label": "Moderator",
"permissions": ["concept:view:all", "concept:edit:all"],
"isSystem": false,
"isBuiltin": false
}
]
}

List Available Permissions

GET /api/v1/roles/available-permissions
Authorization: Bearer <admin-token>

Response:

[
{
"key": "user:view:all",
"entity": "user",
"action": "view",
"scope": "all",
"label": "View All Users",
"description": "View all user records"
}
]

Create Custom Role

POST /api/v1/roles
Authorization: Bearer <admin-token>
Content-Type: application/json

{
"key": "MODERATOR",
"label": "Moderator",
"description": "Can moderate content",
"permissions": ["user:view:all", "content:edit:all"]
}

API Keys

Create API Key

POST /api/v1/apikeys
Authorization: Bearer <token>
Content-Type: application/json

{
"name": "CI/CD Integration",
"permissions": ["user:view:all", "concept:create"],
"expiresAt": "2025-12-31T23:59:59Z"
}

Response:

{
"id": "...",
"name": "CI/CD Integration",
"token": "rk_1234567890abcdef...",
"lastEight": "...cdef1234",
"expiresAt": "2025-12-31T23:59:59Z"
}

⚠️ Important: The token is only returned once. Store it securely.

Use API Key

GET /api/v1/users
Authorization: Bearer rk_1234567890abcdef...

List API Keys

GET /api/v1/apikeys
Authorization: Bearer <token>

Revoke API Key

DELETE /api/v1/apikeys/:id
Authorization: Bearer <token>

Framework Integration

SvelteKit

The generator creates authentication helpers for SvelteKit.

Setup Hook

src/hooks.server.ts:

import { sequence } from '@sveltejs/kit/hooks';
import { telemetryHandle } from '@core/telemetry/middleware';
import { rateLimitHandle } from '@core/middleware/rate-limit';
import { idempotencyHandle } from '@core/middleware/idempotency';
import { authHandle } from '@core/auth/verify';

export const handle = sequence(
telemetryHandle,
rateLimitHandle,
idempotencyHandle,
authHandle
);

This middleware:

  • Validates signed session cookies
  • Loads user and roles
  • Attaches auth to locals.auth
  • Supports API key authentication
  • Composes telemetry, rate limiting, idempotency, and auth middleware

Note: No dev auth bypass is generated. Use the /setup route to create an admin account for local development.

Page Authorization

+page.server.ts:

import { hasPermission } from '.radish/lib/datalayer/core/http/adapters/sveltekit';
import { error } from '@sveltejs/kit';

export async function load({ locals }) {
// Check permission
if (!await hasPermission(locals.auth, 'admin:panel:access')) {
throw error(403, 'Forbidden');
}

// Load admin data
return {
adminData: await loadAdminData()
};
}

Component Usage

+page.svelte:

<script>
export let data;
</script>

{#if data.auth?.userId}
<p>Welcome, {data.auth.displayName}!</p>

{#if data.auth.roles?.includes('ADMIN')}
<a href="/admin">Admin Panel</a>
{/if}
{:else}
<a href="/login">Login</a>
{/if}

Form Actions

+page.server.ts:

import { UserService, createServiceAuthFromLocals } from '@generated/datalayer/services';

export const actions = {
default: async ({ locals, request }) => {
const data = await request.formData();

// Convert locals.auth to ServiceAuth format for services
const auth = createServiceAuthFromLocals(locals.auth);
const service = new UserService(auth);

await service.update(userId, {
displayName: data.get('displayName')
});

return { success: true };
}
};

Fastify

For Fastify projects:

import { authPlugin, requireAuth, requirePermission } from '.radish/lib/datalayer/core/http/fastify';

// Register auth plugin
await fastify.register(authPlugin);

// Protected route
fastify.get('/admin', {
preHandler: [requireAuth, requirePermission('admin:access')]
}, async (request, reply) => {
const user = request.user;
return { message: 'Admin area', user };
});

Service Authorization

Services automatically enforce permissions and ownership.

Permission Checks

import { ConceptService } from '@generated/datalayer/services';

// Create (requires concept:create permission)
const concept = await ConceptService.create(ctx, {
title: 'My Idea',
description: 'Description here'
});

// View all (requires concept:view:all permission)
const allConcepts = await ConceptService.find(ctx, {});

// View own (requires concept:view:own permission)
const myConcepts = await ConceptService.find(ctx, {
ownerId: ctx.userId
});

// Update (requires concept:edit:all or concept:edit:own + ownership)
await ConceptService.update(ctx, conceptId, {
title: 'Updated Title'
});

Auth Types

LocalsAuth - Type for locals.auth in SvelteKit:

interface LocalsAuth {
principal?: Principal; // User or API key principal
roles: string[]; // User's roles
scopes: string[]; // API key scopes
is: (role: string) => boolean; // Helper to check role
has: (scope: string) => boolean; // Helper to check scope
requireRole: (role: string) => void; // Throw if missing role
requireScope: (scope: string) => void; // Throw if missing scope
}

ServiceAuth - Type required by service methods:

interface ServiceAuth {
principal?: Principal;
principalType: 'user' | 'api' | 'anonymous';
userId?: string;
apiKeyId?: string;
roles: string[];
scopes: string[];
permissions: string[];
ownerId?: string;
}

Convert between them using createServiceAuthFromLocals():

import { createServiceAuthFromLocals } from '@generated/datalayer/services';

const serviceAuth = createServiceAuthFromLocals(locals.auth);

System Auth (Server-Side Operations)

For webhooks, background jobs, seed scripts, cron tasks, or any server-side operation without a user session, use systemAuth():

import { ProductService, systemAuth } from '@generated/datalayer/services';

// System-level access — bypasses all permission checks
const svc = new ProductService(systemAuth());
await svc.create({ name: 'Seeded by system' });

With user association (for audit trails):

// Associate the operation with a specific user
const svc = new OrderService(systemAuth(triggeredByUserId));
await svc.update(orderId, { status: 'fulfilled' });

What systemAuth() provides:

PropertyValue
principalType'user'
userId'system' (or provided userId)
roles['ADMIN']
permissions['system:admin']
ownerId'system' (or provided userId)

When to use:

ScenarioAuth to use
SvelteKit page/action with user sessionfromLocals(locals)
API route with user sessioncreateServiceAuthFromLocals(locals.auth)
Webhook handler (no user)systemAuth()
Background job / cronsystemAuth()
Seed scriptsystemAuth()
Server-side AI operationsystemAuth(initiatingUserId)

Important: Prefer user-context auth when a user is available. systemAuth() bypasses all permission checks — use it only when there's genuinely no user context.

Custom Permission Checks

import { hasPermission } from '@generated/datalayer/core/auth/permissions';

async function customLogic(ctx: AuthContext) {
if (await hasPermission(ctx, 'concept:publish')) {
// User can publish
}

if (await hasPermission(ctx, 'system:admin')) {
// User is admin
}
}

Defining Roles

Roles are defined in {app}.roles.json:

{
"version": 1,
"roles": {
"EDITOR": {
"label": "Content Editor",
"description": "Can edit all content",
"isSystem": false,
"permissions": [
"concept:view:all",
"concept:edit:all",
"concept:create",
"user:view:all"
]
},
"MODERATOR": {
"label": "Moderator",
"description": "Can moderate user content",
"isSystem": false,
"permissions": [
"concept:view:all",
"concept:edit:all",
"comment:view:all",
"comment:delete:all"
]
},
"REVIEWER": {
"label": "Reviewer",
"description": "Can approve concepts",
"isSystem": false,
"permissions": [
"concept:view:all",
"concept:approve"
]
}
}
}

After modifying roles, sync without touching users:

radish-cli seed --sync-roles

Role seeding now populates permissions from the blueprint — if your roles.json defines permissions for a role, those permissions are created/linked during seeding.

Note: When a /setup route exists (generated by create app), the seed command skips admin user creation. The setup wizard handles first-time admin onboarding instead.

Built-in Roles

RolePurposeDefault Permissions
ADMINSystem administratorsystem:admin (grants everything)
USERAuthenticated userview:own, create, edit:own on user-owned entities
APIProgrammatic accessapi:access
ANONYMOUSUnauthenticated requestsNone (add via roles.json)
MODERATORContent moderationNone (configure per app)

Overriding Builtin Roles

Builtin roles (USER, ADMIN) have hardcoded permissions. To customize:

  1. Define the role in roles.json with isSystem: false
  2. Add explicit permissions
  3. Run radish-cli seed --sync-roles

The service checks the database first — DB permissions override builtins.

{
"USER": {
"label": "Standard User",
"isSystem": false,
"permissions": [
"product:view:all",
"order:view:own",
"order:create"
]
}
}

Anonymous / Public Access

The ANONYMOUS role is assigned to all unauthenticated requests. Services check RBAC permissions before requiring authentication, so if ANONYMOUS has the right permission, the request proceeds without login.

{
"ANONYMOUS": {
"label": "Anonymous",
"isSystem": false,
"permissions": [
"product:view:all",
"category:view:all"
]
}
}

This makes products and categories publicly readable. Write operations still require authentication.

Security Best Practices

1. Session Security

Radish uses HMAC-SHA256 signed session cookies — not raw user IDs or JWTs stored in localStorage.

How it works:

  • On login/register, the server creates a signed token: userId.timestamp.signature
  • The signature is computed as HMAC-SHA256(userId.timestamp, SESSION_SECRET)
  • The token is set as an httpOnly, Secure, SameSite=Strict cookie
  • On each request, the signature is verified with constant-time comparison
  • Legacy unsigned cookies are rejected (no backward compatibility)

Cookie naming: The cookie name is derived from the app name: {appName}_uid (e.g., myapp_uid). This prevents cookie collisions when running multiple Radish apps on the same domain.

2. Password Requirements

// Configure in user service
const PASSWORD_MIN_LENGTH = 12;
const REQUIRE_SPECIAL_CHARS = true;

3. API Key Management

  • Set expiration dates on all API keys
  • Use principle of least privilege (minimal permissions)
  • Rotate keys regularly
  • Revoke immediately when compromised

4. Permission Scoping

// ✅ Good - Specific permissions
{
"permissions": [
"concept:view:own",
"concept:edit:own"
]
}

// ❌ Avoid - Overly broad
{
"permissions": [
"system:admin"
]
}

5. Rate Limiting

Radish generates a builtin in-memory sliding window rate limiter. Every generated app gets rate limiting automatically — no configuration needed.

Default limits (applied to all /api/ routes via hooks):

TierLimitRoutes
auth20 req/min/api/v1/auth/* (login, register)
api100 req/min per userAll authenticated /api/* routes
public60 req/min per IPAll other /api/* routes

Responses include x-ratelimit-limit, x-ratelimit-remaining, and x-ratelimit-reset headers. When limited, returns 429 with retry-after.

Custom endpoint rate limiting:

For routes outside the generated /api/v1/ paths (e.g., widget endpoints, uploads, webhooks), use the reusable rate limiting utilities:

// Option 1: Direct check in any +server.ts
import { rateLimit } from '@core/middleware/rate-limit';

export const POST: RequestHandler = async (event) => {
const limited = rateLimit(event, { maxRequests: 5, windowMs: 60_000 });
if (limited) return limited;
// ... handle request
};
// Option 2: Register a named tier for reuse across routes
import { registerTier, rateLimit } from '@core/middleware/rate-limit';

registerTier('widget-submit', { maxRequests: 5, windowMs: 60_000 });
registerTier('upload', { maxRequests: 10, windowMs: 300_000 });

// Then in any +server.ts:
const limited = rateLimit(event, 'widget-submit');
if (limited) return limited;
// Option 3: Wrapper pattern for clean endpoint definitions
import { withRateLimit } from '@core/middleware/rate-limit';

export const POST = withRateLimit(
{ maxRequests: 5, windowMs: 60_000 },
async (event) => {
const body = await event.request.json();
return json({ ok: true });
}
);

All three options use the same sliding window implementation and in-memory store as the builtin middleware. For production with multiple instances, swap to Redis-backed storage.

Environment Variables

VariableDescriptionDefault
JWT_SECRETSecret for signing JWT tokensRandom (generated)
JWT_EXPIRES_INToken expiration time7d
LDAP_URLLDAP server URL-
LDAP_BASE_DNLDAP base DN-
LDAP_USER_FILTERLDAP user filter(uid={username})
SESSION_SECRETSecret for signing session cookies (preferred)-
RADISH_ENCRYPTION_KEYFallback secret when SESSION_SECRET is not set-

Setup Wizard

Generated apps include a /setup route for first-time admin creation. This replaces the need to manually seed an admin user.

How It Works

  1. On first launch, hooks.server.ts checks if any admin user exists
  2. If no admin exists, unauthenticated requests are redirected to /setup
  3. The setup form collects: name, email, password, and optionally a tenant entity
  4. On submit, it creates the user, assigns the ADMIN role (with system:admin permission), and signs a session cookie

Tenant Auto-Detection

If your blueprint uses scope.through on entities (e.g., scoping orders through an Organization), the setup wizard automatically detects the tenant entity and includes a field for creating the first tenant record. The new admin is linked to this tenant via the member array field.

Environment Requirements

The setup wizard requires either SESSION_SECRET or RADISH_ENCRYPTION_KEY to be set in .env for cookie signing.

Troubleshooting

"Invalid token" errors

Cause: Token expired or invalid secret.

Solution:

  • Check JWT_SECRET is consistent across deployments
  • Verify token hasn't expired
  • Check Authorization header format: Bearer <token>

Permission denied errors

Cause: User lacks required permission.

Solution:

  1. Check user's roles: GET /api/v1/auth/me
  2. Verify role permissions: GET /api/v1/roles
  3. Update roles blueprint if needed
  4. Regenerate after blueprint changes

LDAP authentication failing

Cause: Incorrect LDAP configuration.

Solution:

  1. Test LDAP connection: GET /api/v1/auth/test-ldap
  2. Verify LDAP_URL, LDAP_BASE_DN environment variables
  3. Check user filter pattern matches your LDAP schema
  4. Review LDAP server logs for connection errors

Next Steps