alepha@docs:~/docs/framework/guides/server$
cat 4-authentication.md | pretty
8 min read
Last commit:

#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.

typescript
 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:

typescript
1issuer = $issuer({2  secret: "my-secret",3});

An external issuer verifies tokens from an external provider (Auth0, Keycloak, etc.) using JWKS:

typescript
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:

typescript
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.

typescript
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:

typescript
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.

typescript
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 registrationAttempt notice 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:

typescript
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:

typescript
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. setMyFirstPassword trusts the session and refuses once a credentials identity exists; changeMyPassword verifies 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 credentials identity 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.

typescript
 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 an onDelete: "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:

typescript
 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:

typescript
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:

typescript
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:

  1. currentUserAtom: checked first. Set by $action.run() fork, MCP transports, pipelines, and jobs.
  2. request.user: HTTP request user set by previous middleware.
  3. HTTP headers: JWT or API key resolved from Authorization header.

#Local Action Calls

When calling an action locally via .run(), pass the user in options:

typescript
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:

typescript
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:

typescript
 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:

typescript
 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.

typescript
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:

typescript
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:

  1. Authentication: Is there a valid user? → UnauthorizedError (401) if not.
  2. Issuers: Does the user's realm match one of the listed issuers? → ForbiddenError (403) if not.
  3. Roles: Does the user have at least one of the listed roles? → ForbiddenError (403) if not.
  4. Permissions: Does the user's role grant all listed permissions? → ForbiddenError (403) if not.
  5. 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

typescript
 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:

typescript
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):

typescript
 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:

typescript
 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();