Skip to main content
Authentication is always on and cannot be disabled. The same image runs on localhost and on a public host, so a disable switch would be a setting somebody forgets to change on the day the instance becomes reachable.

First run

A fresh installation redirects to registration. The account you create there becomes the owner. The first account always bootstraps as owner regardless of AUTH_ALLOW_REGISTRATION, so a locked down deployment can still create its first user. Set AUTH_SECRET before the first boot. The API refuses to start with fewer than 32 characters.

Registration

AUTH_ALLOW_REGISTRATION defaults to false, which closes registration the moment the owner account exists. That is the right default for a single operator deployment. Set it to true when more people need to sign themselves up.

Where a self registration lands

Opening registration decides who may create an account. AUTH_REGISTRATION_WORKSPACE decides what that account can see, and it defaults to own. Pick shared only when everyone who can reach the registration form is already trusted with the data on the instance. Anyone who finds an internet reachable instance with open registration and shared can read and edit the whole workspace, so the safe combination for a public host is open registration on own, or registration closed. A hosted instance refuses to start on shared, because BILLING_ENABLED=true opens registration to strangers by design.

Add somebody to your own workspace

Self registration is not how a teammate joins you. Invite them from Settings, which sends a link that creates their account inside your workspace as a member. That works on own and needs no change to registration at all.

Password rules

A password is between 10 and 128 characters long and must contain at least 10 characters that are not whitespace. Spaces inside a passphrase are fine, so correct horse battery is accepted, while a password made only of spaces, tabs or other whitespace is refused with 400. The same rule applies everywhere a password is chosen: registration, accepting an invitation, redeeming a recovery link and changing the password from the account menu. The password is stored exactly as it was sent. Nothing is trimmed, so the characters you type at sign in are the characters that were chosen. Sign in does not apply the rule. An account created before the rule existed can still sign in with its old password and change it to one that meets the rule, and a wrong password within the length limit is answered with the same 401 whatever it contains.

Sessions

A session is a JWT in an httpOnly, SameSite=Lax cookie named asobeast_session, signed with AUTH_SECRET. Every session also has a record on the server, and the token carries a random id that names it. The API accepts the cookie only while that record exists and has not expired, so a signature alone is not enough. Registering, signing in, confirming an email address and accepting an invitation each open a session. Signing out ends the session you are using on the server, not only in the browser. A copy of that cookie stops working at once, whoever holds it, while your sessions on other devices stay signed in. Records of expired sessions are removed by the daily retention job, so there is nothing to configure. Because the browser talks only to the web origin and the proxy forwards the cookie, there is nothing cross site to configure. A JSON only API plus SameSite=Lax is the CSRF story. Rotating AUTH_SECRET invalidates every existing session, so everyone is signed out. See Rotate secrets.

Rotate a password

Changing your password from the account menu ends every other session, by bumping a session version and deleting their records, while the one you are using keeps working. That is what you want after a laptop is lost. The same write deletes every personal API token the account holds, because a token minted during a session somebody else briefly held would otherwise keep working. Mint the tokens you still need again from Integrations afterwards.

How are sign in attempts throttled?

The authentication endpoints are rate limited per client address. A refused attempt answers 429 with a Retry-After header and a retryAfterSeconds field in the error body, both in whole seconds, and the message names the same wait in words. A wait under a minute is counted in seconds, for example Too many attempts from this address. Try again in 42 seconds. A longer wait is rounded up to whole minutes and counted in hours and minutes, so the hourly limits read Try again in 49 minutes. or Try again in 1 hour. rather than a count of thousands of seconds. The sign in card and the recovery pages show that message as it is. While Redis cannot be reached an attempt cannot be counted, so these endpoints answer 503 with a Retry-After header instead of admitting it. See When Redis is unreachable. Behind a reverse proxy, TRUST_PROXY decides what the client address is. Left at 0 behind a proxy, every browser shares the proxy’s address and therefore one allowance. See Hosting behind TLS and a reverse proxy.

Recover a forgotten password

Somebody who cannot remember their password asks for a recovery link from the sign in card and sets a new one without the operator touching the database. Redeeming the link signs out every session that account had open and revokes every personal API token it held. See Account recovery.

How is a request authorized?

Two guards run in order on every request. A small set of routes is marked public, and account plus paywall routes stay reachable even for an unentitled account so an expired user can sign in and pay. Where a registration lands is the same on a self hosted instance and a hosted one: by default every account after the first gets a workspace of its own, so accounts never see each other’s apps. AUTH_REGISTRATION_WORKSPACE=shared is the opt in that puts them in the bootstrap workspace instead. See Where a self registration lands.

Entitlements and the paid plan seam

BILLING_ENABLED defaults to false, which means every account is entitled and nothing is gated. Setting it to true turns on an entitlement check without wiring a payment provider. Registration stays open, each new mailbox is stamped with a TRIAL_DAYS trial defaulting to 7 (see One trial per mailbox), and an unentitled request answers 402 through the standard error envelope, which the web app catches and turns into the upgrade page. Entitlement lives on the workspace, not the user, because a workspace has one plan whatever the size of the team. The seam writes only plan, trialEndsAt, planExpiresAt, billingCustomerId and subscriptionId on Workspace. The plans are free, trial, indie and ultimate; indie and ultimate are the paid tiers and carry the quota limits, so setting plan to either one in the database restores access with no code change. premium is accepted as a legacy spelling of indie so accounts written before the tiers were named keep working. The plan in force is derived, not just stored. A paid plan past its planExpiresAt falls back to the trial if one is still running, and to free otherwise, so an expired subscription never keeps the limits it paid for.

Email confirmation and the trial

With billing enabled and SMTP configured, registration creates the account but withholds the trial until the address is confirmed. asobeast emails a single use link; opening it stamps the address as confirmed and starts the trial on the workspace. Only a hash of the link token is stored. A workspace gets one trial, ever. trialStartedAt records it and is never cleared, so a cancelled customer who returns resumes on a paid plan rather than on a second free week. A mailbox gets one too, as the next section explains. A workspace that holds a paid plan, or that ever held a subscription, gets none: confirming an address there only marks the address confirmed, including when the customer paid before confirming. Where SMTP is not configured the trial starts at registration, because there is no way to deliver a confirmation. Until the address is confirmed the web app asks the account to confirm instead of asking it to pay. GET /auth/me reports trialAwaitsConfirmation: true for an account that has not confirmed its address, in a workspace that has never had a trial or a subscription and has no plan in force, on an instance that sends confirmation email. The banner, the upgrade page and the plan card then say that confirming starts the free trial, the banner and the upgrade page offer a new link, and the plans stay available to anyone who would rather pay first. The field is false where SMTP is not configured, because the trial starts at registration there, and for a member of a workspace whose trial has already run or that ever held a subscription, because confirming would start nothing. Registration survives a confirmation email that could not be sent, so a mail outage never costs the account. The signed in account asks for a fresh link with POST /auth/verify/resend, which retires the previous one; the confirmation page offers it whenever a link is refused. Opening a link while signed in as someone else is refused before the link is spent, so it stays usable for the account it belongs to. A deployment with billing and SMTP but no WEB_PUBLIC_URL refuses to boot, because every link it sent would carry no host.

One trial per mailbox

A trial is not a reason to register again. With billing enabled, an address is reduced to its mailbox before the trial is decided, and a mailbox gets one trial, ever, however many workspaces or addresses it has been registered under. Dots stay significant everywhere else, because most providers count them. The stored address and the sign in address are never changed: this is only how the trial is decided and how the daily limit on confirmation emails to one recipient is counted. An account whose mailbox already had a trial is created as usual and signs in, but its workspace gets no trial and sits on the paywall until it pays. The same holds for an address on a throwaway domain, taken from the mailchecker list of disposable email providers. That list includes privacy relay alias domains such as duck.com, mozmail.com, passmail.net and the SimpleLogin domains, so an alias from one of them starts no trial either, because one relay account mints as many aliases as it likes. Each refusal is logged as a warning that names the address. Registration answers 201 in every case and GET /auth/me reports entitled: false, trialEndsAt: null and trialAwaitsConfirmation: false, so the web app offers the plans instead of asking for a confirmation that would start nothing. Where an instance sends confirmation email the mailbox is claimed when the address is confirmed, never at registration, so nobody can use up a mailbox they cannot read. Where it sends none the mailbox is claimed at registration, because nothing can prove it there. A claim is kept only for a trial that started: when a subscription reaches the workspace while its trial is being granted, the claim is given back. Each claim is stored as a keyed hash of the mailbox in TrialClaim, an operator table that belongs to no workspace and has no foreign key, so it survives the erasure of the workspace that took the trial. The key is an HMAC of the mailbox under AUTH_SECRET, which means the table alone cannot be tested against a list of known addresses and holds no email. Rotating AUTH_SECRET therefore forgets every claim, and the next registration of a returning mailbox starts a trial again. Every start of the API claims, in the background, the mailbox of every owner whose workspace already had a trial, so an upgrade covers the existing accounts without delaying the start, and a failed attempt is retried every thirty seconds. Until the first pass after an upgrade finishes, a variant of such an owner’s mailbox can still register with a trial. Self hosted instances without billing store no claims. This slows trial farming down; it does not stop someone with a domain of their own, or with many real mailboxes. A card on file or a phone number would be the next step, and neither is required today.

What an unentitled workspace keeps

Data belongs to the customer; capacity is what the plan pays for. An unentitled workspace therefore keeps every read: Reads stay open only to a browser session. A personal API token keeps the account, plan and upgrade routes, the account export among them, so a script can still take the data out, and is refused everywhere else, because the token surface is part of what the plan sells. Nothing is deleted when entitlement lapses.

Team members

A workspace has one owner and any number of members. The owner manages billing and the team; a member does everything else. Nobody belongs to two workspaces. A member sees the plan and its usage read only. The plan card in settings and the upgrade page say that the workspace owner manages billing, and neither offers a checkout or the billing portal, which the API refuses to anyone but the owner.
  • The owner invites by email from Settings, Team. The invitation expires after seven days.
  • asobeast emails the invitation when SMTP and WEB_PUBLIC_URL are both configured. The link then reaches the invited mailbox and nowhere else: the owner’s response carries only the bare /invite page, so nobody but the person who can read that mailbox can accept it.
  • Where mail is not configured, the dialog shows the link for the owner to pass on. After a failed send it shows the link only while registration is closed, because no stranger can then sign up to send an invitation. With registration open and mail configured a failed send shows no link; fix the mail transport and invite again, which retires the earlier link.
  • The invitee opens the link, chooses a password, and joins the existing workspace as a member. The token is single use.
  • The owner can revoke a pending invitation or remove a member. A workspace always keeps its owner.
Invitations are the way to add a teammate when registration is closed, which is the normal self hosted setting.

Troubleshooting

  • You are signed out after a restart. AUTH_SECRET changed. Sessions are signed with it.
  • Everyone was signed out once after an upgrade. The first release with stored sessions refuses the session cookies issued before it, because those carry no session id. Sign in again. Personal API tokens and the MCP server are not affected.
  • You cannot register a second account. Registration closed after the owner account. Set AUTH_ALLOW_REGISTRATION=true.
  • Sessions do not persist behind a proxy. The session cookie is Secure, so a browser discards it over plain HTTP from another machine. Terminate TLS in front of port 3001, compose the Cloudflare Tunnel overlay, or forward the port over SSH and browse http://localhost:3001 in Chrome or Firefox. Keep AUTH_COOKIE_SECURE=true, because the Docker image refuses to boot without it. See Sessions do not persist behind a proxy and Hosting behind TLS and a reverse proxy.
  • Signing in returns to the sign in form. The browser did not keep the session cookie. Safari drops a Secure cookie on plain HTTP even on localhost, so it needs TLS. See Signing in returns to the sign in form.

Personal API tokens

Authenticate a script or an agent.

Hosting behind TLS and a reverse proxy

The checklist before exposing an instance.

Account recovery

What a customer does when they forget their password.
Last modified on October 24, 2026