alepha@docs:~/docs/guides/server$
cat 4-authentication.md | pretty
21 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.

#Several realms: a token belongs to the one that minted it

Realms share a signing key by default, so a signature alone does not say which realm a token came from. What does is its aud claim, which createToken sets to the issuing realm's name; a resolver refuses a token whose audience names a different realm.

Each deployed worker must have its own APP_SECRET. A token minted by another worker then fails signature verification before any claim is read.

That matters when you mint a token by hand. JwtProvider.create's second argument picks a signing key, not a realm, so in a multi-realm app a token minted without an audience names nothing and is refused. Set one:

typescript
1await jwt.create({ sub: user.id, roles, aud: "staff" }, "staff", {2  header: { typ: jwt.accessTokenTyp },3});

A single-realm app is unaffected, and so is a federated issuer: its aud is its IdP's client id, and it is protected by holding its own JWKS instead.

#Name the default realm, or pass one

Some lookups name no realm: a permission check given a bare string, a per-tenant realm name the account carries without a $realm behind it. That used to resolve to whichever realm was declared FIRST, so the order of fields in a class decided it - silently, and with real consequences: an app that declared its staff realm first had verified citizens refused their own permissions, because their roles were resolved among staff roles.

With one realm there is nothing to be ambiguous about and nothing to declare. From the second realm on, say which one answers:

typescript
1class App {2  staff = $realm({ issuer: { name: "staff" } });3  citizens = $realm({ issuer: { name: "citizens", default: true } });4}

Leave it out and a realm-less lookup is refused, naming the realms it could have meant. That is deliberate: a caller that forgot to pass a realm should fail at the call, not resolve against the wrong set.

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

#Letting One Address Into a Closed Realm

settings.registrationAllowed: false closes a realm to everyone. An app that invites people by email needs a third state: closed, except for the addresses it has invited. That is isPreAuthorized.

typescript
 1import { $realm } from "alepha/api/users"; 2  3class Security { 4  realm = $realm({ 5    settings: { registrationAllowed: false }, 6    isPreAuthorized: async ({ email, method, token }) => { 7      // The credentials path has proven nothing yet, so ask for the secret 8      // that arrived in the invitation mail. 9      if (method === "credentials") {10        return token === "the-token-you-mailed-them"11          ? { emailVerified: true }12          : false;13      }14      // The OAuth provider has already proven the address, so a pending15      // invitation for it is enough.16      return email.endsWith("@example.com");17    },18  });19}

It is consulted only when the realm is closed, and at both entry points that can create an account: credentials registration and OAuth first login. A realm that is closed to one and open to the other is not closed.

Three things it deliberately is not:

  • Not a rate-limit bypass. The per-IP registration cap runs before the closed check, so a pre-authorized address is throttled like any other.
  • Not a captcha bypass. captchaRequired is enforced after it. An invitation proves an address, not a human.
  • Not a different answer. A refusal produces the message a closed realm has always produced, so a prober cannot tell an invited address from any other one.

The credentials path carries preAuthToken on the register request body, capped at 512 characters and otherwise opaque: the framework never reads it, validates it or logs it. Returning { emailVerified: true } says the pre-authorization was itself delivered to that mailbox, so no verification code is sent or asked for and the account still lands verified. Say that only when it is true. On the OAuth path the hint is ignored, because there the provider is the authority.

Leave isPreAuthorized unset and a closed realm behaves exactly as it did before this existed.

#The First Account Owns a Self-Hosted Instance

An app distributed as an image has a bootstrap problem: someone runs the container, and there is no administrator yet. bootstrapFirstUser answers it without a seed command, an ADMIN_PASSWORD in shell history, or a password sitting in docker inspect.

typescript
1import { $realm } from "alepha/api/users";2 3class Security {4  realm = $realm({5    bootstrapFirstUser: true,6  });7}

The operator runs the container, registers, and owns the instance from that moment. Two behaviours, and the second is a guard rather than the main path:

  • The first account lands admin. isPreAuthorized cannot do this: admin is a persisted role, granted only from adminEmails / adminUsernames. Both entry points are covered.
  • A closed realm stays reachable while it holds no account. So an operator who sets registrationAllowed: false on a fresh instance does not brick it, with the only account that could reopen it now uncreatable. It logs at warn whenever it fires, because it loosens a setting that was asked for explicitly.

The account promoted is the oldest row in the realm, which is what makes two concurrent registrations produce exactly one administrator. Promoting on "the table was empty when I looked" produces two; re-counting afterwards and promoting on a count of one produces none, which is worse.

The framework warns at boot while no account exists:

txt
No accounts exist. Registration is open and the first account will become the administrator.

That line is the answer to the window between starting an instance and finishing setup, and it is what Gitea, Grafana and Jellyfin all do. Finish setup before the instance is publicly reachable.

Refused on serverless. A freshly deployed Worker with an empty users table would hand admin to whoever registered first, and the lookup would run on every isolate forever. An app that ships both a Worker and a container gates the option on !alepha.isServerless() and grants its administrator through adminEmails on the Worker.

#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});

Seven providers are available:

identities key Primitive Protocol Environment variables
credentials $authCredentials - none
google $authGoogle OIDC GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
github $authGithub OAuth2 GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET
microsoft $authMicrosoft OIDC MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, optional MICROSOFT_TENANT_ID
apple $authApple OIDC APPLE_CLIENT_ID, APPLE_CLIENT_SECRET
facebook $authFacebook OAuth2 FACEBOOK_CLIENT_ID, FACEBOOK_CLIENT_SECRET
franceconnect $authFranceConnect OIDC FRANCECONNECT_CLIENT_ID, FRANCECONNECT_CLIENT_SECRET

A provider whose two variables are not both set is disabled rather than broken: the app boots, the button is absent, and nothing throws. That is what lets one codebase run with Google in production and without it locally.

#Declaring a provider yourself

identities: { google: true } is shorthand. It declares $authGoogle(realm) for you, and every one of these primitives can be declared directly when you need to pass options the shorthand does not expose:

typescript
 1import { $realm } from "alepha/api/users"; 2import { $authGoogle } from "alepha/server/auth"; 3  4class Auth { 5  realm = $realm({ identities: { credentials: true } }); 6  7  google = $authGoogle(this.realm, { 8    scope: 9      "openid profile email https://www.googleapis.com/auth/calendar.readonly",10    authorizationParameters: { prompt: "consent", access_type: "offline" },11  });12}

The first argument is always the realm, which is what supplies the link function that turns a provider profile into a local account. The second is a partial OIDC or OAuth2 configuration merged over the defaults.

Three of them have quirks worth knowing before you enable them:

  • Apple requires response_mode=form_post and the scope name email (there is no standard profile scope), and it sends the user's name only on the very first authorization, as a form field on the POST callback. The framework extracts it into the profile for you; a second login returns nothing but sub and email. Its client secret is not a string you copy from a console, it is an ES256 JWT you sign from your Apple private key, valid for at most six months. Rotate it before it expires or logins stop.
  • Microsoft defaults to the common tenant, which accepts personal, work and school accounts. Set MICROSOFT_TENANT_ID to restrict it. Its discovery document for common returns a literal {tenantid} placeholder as the issuer; that is expected and handled.
  • France Connect requires acr_values=eidas1 (added automatically) and makes logout mandatory: store the id_token from login and pass it to the logout endpoint when the session ends.

#$auth, underneath all of them

Every shorthand above returns an $auth. Declare it directly for a provider that has no shorthand, or for an identity provider that is not yours at all:

typescript
 1import { $realm } from "alepha/api/users"; 2import { $auth } from "alepha/server/auth"; 3  4class Auth { 5  realm = $realm({ identities: { credentials: true } }); 6  7  gitlab = $auth({ 8    issuer: this.realm, 9    name: "gitlab",10    oidc: {11      issuer: "https://gitlab.com",12      clientId: String(process.env.GITLAB_CLIENT_ID),13      clientSecret: process.env.GITLAB_CLIENT_SECRET,14    },15  });16}

It takes one of three configurations, and which one you pass decides the flow:

Config Flow Use for
credentials Client credentials Your own login form against your own user store
oauth Authorization code, OAuth2 A provider with no OIDC discovery document
oidc Authorization code, OIDC Anything that publishes /.well-known

With issuer set to your realm, Alepha mints the tokens and the external provider only proves identity. Pass oidc without issuer instead and the relationship inverts: the external provider (Keycloak, Auth0) issues the tokens and your app verifies them. That is the AuthExternal shape, and it is the one to use when an identity server already exists and your app is a relying party rather than the authority.

OIDC discovery is always lazy. A relying party has to boot when its identity provider is briefly unreachable, so the discovery request happens on the first authentication rather than at startup.

#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. API keys, their expiry, rotation and scope have a guide of their own: API keys.

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

#Declaring roles and permissions as primitives

The roles array on $issuer is one way to write this. $role and $permission from alepha/security are the other: the same registry, declared where the code they govern lives rather than in one central list.

typescript
 1import { $permission, $role } from "alepha/security"; 2  3class ArticlePermissions { 4  publish = $permission({ 5    description: "Publish an article to the public site", 6  }); 7  8  editor = $role({ 9    permissions: ["articles:*", "media:upload"],10  });11}

A $permission takes its name from the property key and its group from the class name, so publish in ArticlePermissions is ArticlePermissions:publish. Both are overridable with name and group.

The reason to prefer these over the roles array is locality. A permission declared in the file that enforces it is deleted when that file is, whereas a central list quietly accumulates permissions for features that no longer exist. The reason to prefer the array is that it is one place to look. Mixing them is fine; the registry is shared.

Both primitives can answer questions directly, which is what you want in a handler that has to branch rather than refuse:

typescript
1if (this.permissions.publish.can(user)) {2  // show the button3}

$permission.can(user) resolves the user's role names inside their own realm. That scoping is not incidental: without it, a realm whose admin role grants nothing would inherit the permissions of whichever homonymous admin another realm happened to declare first.

$role.can(permission) and $role.check(permission) ask the same question from the other side, given only a role name.

#The registry as a catalogue

Everything the application ever named is in one registry: $secure() registers a bare entry for every string it is handed, $permission() adds the rest. SecurityProvider.permissionCatalogue() reads that registry back as ordered groups, which is what a screen offering permissions - a role editor, a per-resource rank matrix - renders itself from.

typescript
 1import { $permission } from "alepha/security"; 2  3class ArticlePermissions { 4  publish = $permission({ 5    name: "publish", 6    group: "article", 7    label: "permissions.article.publish", 8    groupLabel: "permissions.article.group", 9    groupOrder: 2,10  });11}

label and groupLabel are translation keys, not text: a localised UI renders the matrix in the reader's language, and description is a plain string that cannot do that. groupOrder places the section, because alphabetical is not the order anyone thinks in; a group nobody ordered sorts after every group somebody did.

A group has no declaration of its own - it exists because permissions name it - so its label and order are declared on the permissions inside it, and every permission in one group must agree. Two declarations that disagree are refused when the second one registers, rather than resolved by load order. For the same reason, a later declaration enriches an earlier bare one: a $secure that initialises before its $permission no longer swallows the label.

⚠️ group:name is a stable id. Whatever grants a permission stores that string, so renaming either half silently re-points every grant that held the old one, with nothing going red. Never derive an id from a position in an array either: inserting a permission in the middle then re-maps every stored grant, and the grants that move are not the ones being edited. Say what a permission is called with label, which nothing stores and which is free to change.

#issuer on a role, and the ordering trap

A $role with no issuer attaches to every realm, including realms declared after it. Name one to scope it:

typescript
 1import { $issuer, $role } from "alepha/security"; 2  3class Security { 4  staff = $issuer({ secret: "change-me" }); 5  6  admin = $role({ 7    issuer: this.staff, 8    permissions: ["*"], 9  });10}

Declaration order matters here, and it is enforced rather than silent. An $issuer referenced by value only resolves once the container has walked that property, so a $role written above the $issuer in the same class reads nothing and would attach to every realm: exactly what naming an issuer was meant to prevent. That case throws at startup, telling you to declare the $issuer first or to name its realm as a string.

#$secure Options

$secure() accepts five 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  sessionOnly?: boolean,6  guard?: (ctx: SecureGuardContext) => Async<boolean>,7})

sessionOnly refuses any identity authenticated by a machine credential (an API key or an OAuth access token): see Permission scope and machine credentials.

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. With sessionOnly, is it a signed-in session rather than a machine credential? → ForbiddenError (403) 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, and does the credential's permissionScope admit them? → 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}

#Permission scope and machine credentials

Two things an identity can carry, besides its roles, change what $secure and SecurityProvider answer for it.

#permissionScope: below the roles, never above

permissionScope caps what a credential may do, below what its roles grant:

  • undefined is unrestricted. Every signed-in session is on this branch, so nothing changes for an identity that never had a scope.
  • An array admits a permission only when one of its entries matches it: a permission name, or a pattern such as project:* or *.
  • [] matches nothing, so every permission-checked route refuses. It reads like a no-op and is a denial.

The scope is applied wherever a permission is read, not only in $secure: checkUserPermission, getPermissions(user) (so a scoped caller is never shown actions it cannot call, in /api/_links or elsewhere) and permissionCatalogueFor(user) all intersect with it.

⚠️ A scope binds permission-checked routes only. A route that declares no permissions (a bare $secure(), or $secure({ roles })) admits a scoped credential whatever its scope says. Declare a permission on a route a scope is meant to keep a credential out of.

Where a scope comes from:

  • An API key created with permissions carries them as its scope. See API keys.
  • An OAuth access token carries the permissions its granted scopes declare, as below.

#credential: a machine credential is not a session

An identity authenticated by an API key or by a connected app's OAuth access token carries a credential marker ({ type: "api-key" | "oauth", id }); a signed-in session carries none. SecurityProvider.isMachineCredential(user) reads it, and $secure({ sessionOnly: true }) refuses it with a 403.

Declare sessionOnly on every route that mints or revokes credentials, approves an OAuth grant, or changes the account. The framework does so on the API key routes that create, rotate and revoke, on OAuth consent and device approval, and on every non-GET route under /users/me. A machine credential still reads, and still calls the permission-checked actions its roles and scope allow.

#OAuth scopes declare what a token reaches

When the realm runs the OAuth authorization server, each scope an app offers is declared in oauthOptions.scopes with the sentence the consent screen shows, and the permissions a token granted it may use:

typescript
 1import { oauthOptions } from "alepha/api/oauth"; 2  3alepha.set(oauthOptions, { 4  scopes: { 5    openid: { label: "Who you are", permissions: [] }, 6    projects: { 7      label: "Your projects", 8      description: "Read and manage the projects you are a member of.", 9      permissions: ["project:*", "quest:*"],10    },11  },12});
  • A grant reaches the union of its scopes' permissions, which becomes the access token's permissionScope, resolved again at every refresh.
  • permissions: [] reaches nothing, which is right for an identity scope.
  • A scope declared without permissions leaves the whole grant unrestricted, as every grant was before scopes narrowed anything, and the server logs a warning at boot for each one. Declare them.
  • Once any scope is declared, everything else fails closed. Dynamic registration refuses a scope nobody declared (invalid_client_metadata), an authorization request whose scopes the client holds none of is sent back with error=invalid_scope, and a device flow must name at least one declared scope (a registered client, one of its own). A stored grant naming an undeclared scope, or none, reaches no permission-checked route. An app that declares no scope at all keeps granting unrestricted tokens.

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

An empty password is refused when the middleware is declared, so an app whose secret is unset fails to start rather than letting in anyone who sends the username with no password. Declare the variable as required, as above, rather than reading an optional one as ?? "".

To put a whole path family behind the same credentials (pages and static files included, not only actions), hand it to $middleware:

typescript
 1import { $env, z } from "alepha"; 2import { $basicAuth } from "alepha/security"; 3import { $middleware } from "alepha/server"; 4  5class SiteAuth { 6  protected readonly env = $env(z.object({ SITE_PASSWORD: z.text() })); 7  8  site = $middleware({ 9    path: "/",10    exclude: ["/health"],11    use: [$basicAuth({ username: "admin", password: this.env.SITE_PASSWORD })],12  });13}

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