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
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 SESSION_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​