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 permissionsADMIN- Full system accessAPI- 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):
- Request body
ldapConfigparameter - Database settings (key: 'ldap')
- 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:
| Property | Value |
|---|---|
principalType | 'user' |
userId | 'system' (or provided userId) |
roles | ['ADMIN'] |
permissions | ['system:admin'] |
ownerId | 'system' (or provided userId) |
When to use:
| Scenario | Auth to use |
|---|---|
| SvelteKit page/action with user session | fromLocals(locals) |
| API route with user session | createServiceAuthFromLocals(locals.auth) |
| Webhook handler (no user) | systemAuth() |
| Background job / cron | systemAuth() |
| Seed script | systemAuth() |
| Server-side AI operation | systemAuth(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
/setuproute exists (generated bycreate app), the seed command skips admin user creation. The setup wizard handles first-time admin onboarding instead.
Built-in Roles
| Role | Purpose | Default Permissions |
|---|---|---|
ADMIN | System administrator | system:admin (grants everything) |
USER | Authenticated user | view:own, create, edit:own on user-owned entities |
API | Programmatic access | api:access |
ANONYMOUS | Unauthenticated requests | None (add via roles.json) |
MODERATOR | Content moderation | None (configure per app) |
Overriding Builtin Roles
Builtin roles (USER, ADMIN) have hardcoded permissions. To customize:
- Define the role in roles.json with
isSystem: false - Add explicit permissions
- 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):
| Tier | Limit | Routes |
|---|---|---|
auth | 20 req/min | /api/v1/auth/* (login, register) |
api | 100 req/min per user | All authenticated /api/* routes |
public | 60 req/min per IP | All 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
| Variable | Description | Default |
|---|---|---|
JWT_SECRET | Secret for signing JWT tokens | Random (generated) |
JWT_EXPIRES_IN | Token expiration time | 7d |
LDAP_URL | LDAP server URL | - |
LDAP_BASE_DN | LDAP base DN | - |
LDAP_USER_FILTER | LDAP user filter | (uid={username}) |
SESSION_SECRET | Secret for signing session cookies (preferred) | - |
RADISH_ENCRYPTION_KEY | Fallback 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
- On first launch,
hooks.server.tschecks if any admin user exists - If no admin exists, unauthenticated requests are redirected to
/setup - The setup form collects: name, email, password, and optionally a tenant entity
- On submit, it creates the user, assigns the ADMIN role (with
system:adminpermission), 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_SECRETis consistent across deployments - Verify token hasn't expired
- Check Authorization header format:
Bearer <token>
Permission denied errors
Cause: User lacks required permission.
Solution:
- Check user's roles:
GET /api/v1/auth/me - Verify role permissions:
GET /api/v1/roles - Update roles blueprint if needed
- Regenerate after blueprint changes
LDAP authentication failing
Cause: Incorrect LDAP configuration.
Solution:
- Test LDAP connection:
GET /api/v1/auth/test-ldap - Verify
LDAP_URL,LDAP_BASE_DNenvironment variables - Check user filter pattern matches your LDAP schema
- Review LDAP server logs for connection errors
Next Steps
- Roles & Permissions Blueprint - Define custom roles
- Access Control - Ownership and field-level security
- Generated Services - Using authenticated services