#Authentication
Alepha provides JWT-based authentication through $issuer for token management and $realm for full user management.
#Token Management with $issuer
$issuer is the low-level primitive for creating and verifying JWT tokens. Use it when you manage users yourself or integrate with an external identity provider.
1import { $issuer } from "alepha/security"; 2import { $action } from "alepha/server"; 3import { t } from "alepha"; 4 5class AuthController { 6 issuer = $issuer({ 7 secret: "your-secret-key", 8 }); 9 10 login = $action({11 method: "POST",12 path: "/auth/login",13 schema: {14 body: t.object({15 email: t.email(),16 password: t.text(),17 }),18 },19 handler: async ({ body }) => {20 const user = await this.authenticate(body.email, body.password);21 return this.issuer.createToken(user);22 },23 });24}
#Internal vs External Issuers
An internal issuer signs and verifies tokens with a shared secret:
1issuer = $issuer({2 secret: "my-secret",3});
An external issuer verifies tokens from an external provider (Auth0, Keycloak, etc.) using JWKS:
1issuer = $issuer({2 jwks: () => process.env.AUTH0_JWKS_URL,3 profile: (payload) => ({4 id: payload.sub,5 email: payload.email,6 name: payload.name,7 }),8});
#Token Lifecycle
$issuer manages access tokens and refresh tokens:
| Setting | Default |
|---|---|
| Access token expiration | 15 minutes |
| Refresh token expiration | 30 days |
Override via the settings option:
1issuer = $issuer({2 secret: "...",3 settings: {4 accessToken: { expiration: [1, "hours"] },5 refreshToken: { expiration: [90, "days"] },6 },7});
#User Management with $realm
$realm is a higher-level primitive that wraps $issuer with built-in user management: registration, login, sessions, password handling, and identity providers.
1import { $realm } from "alepha/api/users";2 3class App {4 realm = $realm();5}
$realm ships with two default roles:
- admin -- Full access to all resources and permissions.
- user -- Access to owned resources only.
#Identity Providers
Enable login methods through the identities option:
1realm = $realm({2 identities: {3 credentials: true, // email/password (default)4 google: true, // Google OAuth5 github: true, // GitHub OAuth6 },7});
#Securing Actions
Actions are public by default. To require authentication, add the $secure() middleware:
1import { $secure } from "alepha/security"; 2 3publicEndpoint = $action({ 4 handler: () => "anyone can access this", 5}); 6 7protectedEndpoint = $action({ 8 use: [$secure()], 9 handler: () => "only authenticated users",10});
The authenticated user is available on the request object:
1profile = $action({2 path: "/me",3 use: [$secure()],4 handler: async ({ user }) => {5 return user;6 },7});
You can also restrict access to a specific issuer or role:
1adminOnly = $action({2 use: [$secure({ issuers: ["admin"] })],3 handler: () => "admin issuer only",4});5 6managersOnly = $action({7 use: [$secure({ roles: ["manager", "admin"] })],8 handler: () => "managers and admins only",9});
#User Resolution
$secure() resolves the authenticated user using atom-first resolution, which works across all transports:
currentUserAtom— checked first. Set by$action.run()fork, MCP transports, pipelines, and jobs.request.user— HTTP request user set by previous middleware.- HTTP headers — JWT or API key resolved from
Authorizationheader.
#Local Action Calls
When calling an action locally via .run(), pass the user in options:
1// Pass a specific user2await controller.action.run({}, { user: { id: "user-1", roles: ["admin"] } });3 4// Use the system user5await controller.action.run({}, { user: "system" });6 7// Use the user from the current HTTP request8await controller.action.run({}, { user: "context" });
The user is scoped to the action call using ALS fork isolation — it does not leak to subsequent calls.
In test mode, .fetch() automatically creates a JWT token from the user option:
1// Automatic test token creation2const res = await controller.action.fetch({}, { user: { id: "test-user" } });
#Roles and Permissions
Define roles with permission sets in the issuer:
1issuer = $issuer({ 2 secret: "...", 3 roles: [ 4 { 5 name: "admin", 6 permissions: [{ name: "*" }], 7 }, 8 { 9 name: "editor",10 permissions: [11 { name: "articles:*" },12 { name: "media:upload" },13 { name: "admin:articles:*" },14 ],15 },16 {17 name: "viewer",18 permissions: [19 { name: "articles:list" },20 { name: "articles:get" },21 ],22 },23 ],24});
#Wildcard Permissions
Permissions use a colon-separated hierarchy. The * wildcard matches everything at and below its level:
| Pattern | Matches | Does not match |
|---|---|---|
* |
Everything (admin access) | — |
articles:* |
articles:list, articles:get, articles:delete |
media:upload |
admin:articles:* |
admin:articles:list, admin:articles:update |
admin:users:list |
Permissions declared in $secure({ permissions: [...] }) are auto-created in the permission registry at definition time — no separate registration step is needed.
#Ownership
The ownership flag restricts a permission to resources owned by the user:
1{ 2 name: "user", 3 permissions: [ 4 { 5 name: "*", 6 ownership: true, 7 exclude: ["admin:*"], 8 }, 9 ],10}
This grants access to all actions, but only for the user's own resources. The exclude array removes specific permission patterns — here, all admin-namespaced actions are excluded entirely.
#$secure Options
$secure() accepts four options. All are optional — when none are provided, it only checks authentication.
1$secure({2 issuers?: string[],3 roles?: string[],4 permissions?: (string | Permission)[],5 guard?: (user: UserAccountToken) => boolean,6})
#Check Order
When multiple options are provided, checks run in this fixed order. Each check must pass before the next runs:
- Authentication — Is there a valid user? →
UnauthorizedError(401) if not. - Issuers — Does the user's realm match one of the listed issuers? →
ForbiddenError(403) if not. - Roles — Does the user have at least one of the listed roles? →
ForbiddenError(403) if not. - Permissions — Does the user's role grant all listed permissions? →
ForbiddenError(403) if not. - Guard — Does the custom function return
true? →ForbiddenError(403) if not.
#AND vs OR Logic
- Issuers — OR: user must match at least one of the listed issuers.
- Roles — OR: user must have at least one of the listed roles.
- Permissions — AND: user must have all listed permissions.
- Options — AND: all provided options must pass.
#Examples
1// Auth only — any authenticated user 2profile = $action({ 3 use: [$secure()], 4 handler: ({ user }) => user, 5}); 6 7// Role check (OR) — admin or manager 8dashboard = $action({ 9 use: [$secure({ roles: ["admin", "manager"] })],10 handler: () => { /* ... */ },11});12 13// Permission check (AND) — must have both14publish = $action({15 use: [$secure({ permissions: ["articles:create", "articles:publish"] })],16 handler: () => { /* ... */ },17});18 19// Issuer restriction20adminPanel = $action({21 use: [$secure({ issuers: ["admin"] })],22 handler: () => { /* ... */ },23});24 25// Custom guard — runs after all other checks26ownProfile = $action({27 use: [$secure({ guard: (user) => user.id === params.id })],28 handler: () => { /* ... */ },29});30 31// Combining options — all must pass32adminManage = $action({33 use: [$secure({34 issuers: ["main"],35 roles: ["admin"],36 permissions: ["admin:manage"],37 guard: (user) => !!user.email,38 })],39 handler: () => { /* ... */ },40});
#Browser Behavior
On the server, $secure throws errors (401/403). In the browser, it returns undefined instead — the handler is never called. Use action.can() to conditionally render UI:
1// Browser: returns undefined if unauthorized, "ok" if authorized2const result = await action();3 4// Use action.can() to check without calling5if (action.can()) {6 // render the button7}
#HTTP Basic Auth
$basicAuth provides HTTP Basic Authentication for simple use cases (webhooks, internal tools):
1import { $basicAuth } from "alepha/security";2 3webhook = $action({4 use: [$basicAuth({ username: "stripe", password: process.env.WEBHOOK_SECRET })],5 handler: ({ body }) => { /* ... */ },6});
Uses timing-safe comparison to prevent timing attacks. Returns 401 with WWW-Authenticate header on failure.
#Service Accounts
$serviceAccount manages tokens for service-to-service communication:
1import { $serviceAccount } from "alepha/security"; 2 3// OAuth2 client credentials 4external = $serviceAccount({ 5 oauth2: { 6 url: "https://provider.com/oauth2/token", 7 clientId: process.env.CLIENT_ID, 8 clientSecret: process.env.CLIENT_SECRET, 9 },10});11 12// JWT-based (internal issuer)13internal = $serviceAccount({14 issuer: myIssuer,15 user: { id: "batch-worker" },16});17 18// Usage: tokens are cached and auto-refreshed19const token = await external.token();