#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 { z } 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: z.object({15 email: z.email(),16 password: z.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.
#Settings That Send a Code Need features.notifications
verifyEmailRequired, verifyPhoneRequired and resetPasswordAllowed each
complete only by delivering a code. Turning one on without
features: { notifications: true } is refused at boot:
1class App {2 // Throws: sets resetPasswordAllowed but features.notifications is off.3 realm = $realm({4 settings: { resetPasswordAllowed: true },5 });6}
The feature flag is all it takes - it registers the notifications module itself, so there is no separate import to remember. What that module then does with the mail (templates, suppression, unsubscribe, delivery receipts) is covered in Notifications and Email.
1import { $realm } from "alepha/api/users";2 3class App {4 realm = $realm({5 features: { notifications: true },6 settings: { resetPasswordAllowed: true },7 });8}
Settings you never mention are unaffected; all three default to false.
#Registration Does Not Confirm Who Has an Account
Registration is deliberately unhelpful about which identifiers are already taken, because a helpful answer is an account-enumeration oracle: post an address, read the error, learn whether that person has an account here.
The behavior depends on verifyEmailRequired:
- Verification on: an address already on file gets the same response a
fresh one gets: an intent id and "check your inbox". No verification code is
minted, so the intent can never be completed. The real owner is emailed a
registrationAttemptnotice instead, which carries no code and asks for no action. - Verification off: a taken username, email or phone all produce one identical error. It never names the field that collided.
Server logs still record which identifier it was, at debug level.
If you present registration errors in your own UI, do not try to map the generic conflict back to a specific field - there is nothing to map it to, and re-deriving it client-side would reopen the hole.
#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});
#An OAuth Sign-Up Is Only as Verified as the Provider Says
A first OAuth login creates the local account, and its emailVerified flag
follows the provider's email_verified claim. A provider that sends false
produces an unverified account and the ordinary verification is sent.
A provider that sends no claim has asserted nothing at all.
trustProviderEmail decides what to do with those, and defaults to true, so
the major providers (Google, Microsoft, Apple, GitHub all send the claim) are
unaffected either way. Turn it off for a realm that accepts logins from a
provider where anyone can claim an address they do not own:
1import { $realm } from "alepha/api/users";2 3class App {4 realm = $realm({5 features: { notifications: true },6 // A provider that stays silent about the address is not believed.7 settings: { trustProviderEmail: false },8 });9}
A claim of false is honoured whatever this setting says. It only ever
decides the silent case.
#Self-Service Account Endpoints
alepha/api/users ships the endpoints an account area needs, all under
/users/me. Every one carries a bare $secure() (a session and no permission)
and resolves the row from user.id. None of them takes an id parameter,
which is what makes it safe to leave them un-permissioned: a caller can only
ever ask about themselves. Operators go through the Admin* controllers, which
have their own permissions.
Declared paths are shown; as $actions they serve under the /api prefix
(GET /api/users/me).
| Controller | Endpoints |
|---|---|
MyProfileController |
GET/PATCH /users/me |
MyAvatarController |
POST/DELETE /users/me/avatar |
MyIdentityController |
GET /users/me/identities, POST /users/me/identities/password, DELETE /users/me/identities/:id |
MyPasswordController |
POST /users/me/password |
MySessionController |
GET /users/me/sessions, DELETE /users/me/sessions/:id, POST /users/me/sessions/revoke-others |
MyConnectionController |
GET /users/me/connections, DELETE /users/me/connections/:id |
MyAccountController |
DELETE /users/me |
Two rules are worth knowing before you wire a UI to them:
- Setting a first password and changing one are different endpoints.
setMyFirstPasswordtrusts the session and refuses once acredentialsidentity exists;changeMyPasswordverifies the current password and revokes every other session. Using the first to change a password would make an unattended signed-in browser a full account takeover. - Unlinking the last identity is refused. An account with no sign-in method
is not locked, it is unreachable - and password reset cannot recover it,
because that needs a
credentialsidentity to reset.
@alepha/ui provides the matching UI as AccountRouter - see the
frontend routing guide.
#Deleting an Account: the user:delete:before Hook
deleteMyAccount is a hard delete, and it asks for two independent proofs: the
current password (that it is you) and the account's email typed verbatim
(that you meant it). An OAuth-only account has no password to prove, so the
confirmation stands alone.
The framework only knows about users, identities and sessions. It cannot know
what your application hangs off a user id, so it emits user:delete:before
first and awaits it. A handler that throws aborts the deletion, and the
error reaches the caller unwrapped - with its own status and message.
The hook lives in UserService.deleteUser, which every deletion goes through:
self-service, AdminUserController's single delete, and its bulk delete. One
account, one set of consequences, whoever pressed the button.
1class UserDeletionHook { 2 protected readonly projects = $repository(projects); 3 4 onUserDelete = $hook({ 5 on: "user:delete:before", 6 handler: async ({ userId }) => { 7 const owned = await this.projects.count({ createdBy: { eq: userId } }); 8 if (owned > 0) { 9 throw new ConflictError(`You still own ${owned} project(s).`);10 }11 },12 });13}
Write one if you have foreign keys to
users.id. Without it you are trusting your own cascade rules, and the failure mode is silent: a column with no foreign key leaves orphaned rows pointing at a user that no longer exists, and anonDelete: "cascade"column can delete rows the account authored inside other people's data. Neither is visible in a diff.
#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: [{ name: "articles:list" }, { name: "articles:get" }],19 },20 ],21});
#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?: (ctx: SecureGuardContext) => Async<boolean>,6})
The guard receives a context object - { user, params, query, body, request?, alepha } - and may be async:
1guard: ({ user, params }) => user.id === params.id;
#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});14 15// Permission check (AND) - must have both16publish = $action({17 use: [$secure({ permissions: ["articles:create", "articles:publish"] })],18 handler: () => {19 /* ... */20 },21});22 23// Issuer restriction24adminPanel = $action({25 use: [$secure({ issuers: ["admin"] })],26 handler: () => {27 /* ... */28 },29});30 31// Custom guard - runs after all other checks32ownProfile = $action({33 use: [$secure({ guard: ({ user, params }) => user.id === params.id })],34 handler: () => {35 /* ... */36 },37});38 39// Combining options - all must pass40adminManage = $action({41 use: [42 $secure({43 issuers: ["main"],44 roles: ["admin"],45 permissions: ["admin:manage"],46 guard: ({ user }) => !!user.email,47 }),48 ],49 handler: () => {50 /* ... */51 },52});
#Browser Behavior
On the server, $secure throws errors (401/403). In the browser, it returns undefined instead - the handler is never called. On $client virtual actions, can() checks authorization without calling:
1// Browser: returns undefined if unauthorized, "ok" if authorized2const result = await action();3 4// $client actions expose can() to check without calling5if (client.myAction.can()) {6 // render the button7}
#HTTP Basic Auth
$basicAuth provides HTTP Basic Authentication for simple use cases (webhooks, internal tools):
1import { $env, z } from "alepha"; 2import { $basicAuth } from "alepha/security"; 3 4class WebhookController { 5 protected readonly env = $env(z.object({ WEBHOOK_SECRET: z.text() })); 6 7 webhook = $action({ 8 use: [ 9 $basicAuth({ username: "stripe", password: this.env.WEBHOOK_SECRET }),10 ],11 handler: ({ body }) => {12 /* ... */13 },14 });15}
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 { $env, z } from "alepha"; 2import { $serviceAccount } from "alepha/security"; 3 4env = $env(z.object({ CLIENT_ID: z.text(), CLIENT_SECRET: z.text() })); 5 6// OAuth2 client credentials 7external = $serviceAccount({ 8 oauth2: { 9 url: "https://provider.com/oauth2/token",10 clientId: this.env.CLIENT_ID,11 clientSecret: this.env.CLIENT_SECRET,12 },13});14 15// JWT-based (internal issuer)16internal = $serviceAccount({17 issuer: myIssuer,18 user: { id: "batch-worker" },19});20 21// Usage: tokens are cached and auto-refreshed22const token = await external.token();