Skip to main content

Security & authentication

Ever Async's API is writable. Authentication is optional and off by default. Those two sentences are only compatible on a private network, and this page is about the difference.

The threat model, in plain words​

/api/v1 is not a read-only status page. Three of its routes change how the product behaves for everyone in the workspace:

A caller who can reach /api/v1 can……by calling
Silence the bot in a channel — or make it loud enough that the team mutes itPUT /api/v1/policies/{channel}/{conversation} with a policy carrying enabled: false, or sensitivity: "high". The body is a whole policy object, never a partial one
Rewrite the team charter — the rules every nudge quotes back to an authorPUT /api/v1/charter
Make the product message your team — a real digest, delivered to real Slack DMs and every configured notify providerPOST /api/v1/digest/send

The reads are less dramatic but not nothing: GET /api/v1/status enumerates every configured plugin and its version, GET /api/v1/digest returns activity counters, GET /api/v1/policies/… confirms which conversations are being watched, and /metrics names every configured channel and counts every conversation.

Two things are not in the threat model, and it is worth being precise about them:

  • No endpoint returns a secret. Credentials live only in environment variables (the *_env rule), never in the config file and never in a response body. status returns plugin ids, names, versions and the selected model — not keys.
  • No endpoint returns message text. Metric labels are channel and connector ids, outcomes and action kinds; no counter or label carries what anyone said.

So an exposed deployment is an integrity and availability problem — someone can turn the bot off, change what it says, or make it send — plus a real leak of operational detail about your tooling and traffic. That is bad enough.

On a private network, behind a VPN, this is all fine. It is the deployment the product was designed for: one binary, one file, one SQLite database, on a box only your team can reach. Reachable from the internet with no credential, it is not fine.

This is not hypothetical

A public deployment of Ever Async served the writable /api/v1 surface with no credential at all. Anyone who knew the hostname could have disabled the bot in a channel or rewritten the charter. Nothing in the design prevented it — auth had simply not been built yet, and "keep it on a private network" was a sentence in the documentation rather than a check in the code.

An ingress-level basic-auth gate went in as the immediate stop-gap. The [auth] block on this page is the real fix, and everasync serve now warns at startup when auth is off and the bind is not loopback — so the sentence is a check now.

If you took the deployment instructions literally and put Ever Async on a public hostname, assume you had the same hole, and work the checklist.

The two modes​

[auth]
mode = "local_trusted" # the default; the whole block may be omitted
ModeCredential required?Who is the caller?
local_trusted (default)NoEvery request is the local board principal (user_id = "local"), so every handler's role check passes
authenticatedYes, on every route outside the public allowlistWhoever the configured provider says it is

"strict" is accepted as a synonym for "authenticated", because that is the word ever-co/ever-os uses for the same state and operators should not have to relearn it.

Why local_trusted is the right default​

Because the common self-hosted case is a private network, and a login wall there is friction with no attacker on the other side of it. Concretely:

  • Upgrading changes nothing. A deployment that has never heard of [auth] keeps working after an upgrade that adds it. No config change, no login, no surprise 401 on a Monday morning.
  • The five-minute path stays five minutes. Getting started does not become "and now set up a user store".
  • It is honest about where the boundary is. In local_trusted the network is the boundary. Making that explicit in the config is better than a half-hearted default nobody trusts either way.

The cost of that default is that turning it on is your job — which is why the server says so out loud when the shape looks wrong.

Misconfiguration fails closed​

Two guards, in opposite directions.

Asking for auth and not getting it is fatal. In authenticated mode the server refuses to start if provider is missing, if the named provider has no [auth.providers.<id>] section, if that provider was compiled out of the binary, or if the provider rejects its section (an empty token list, a malformed user entry). Booting anyway would serve a writable API with no credential to an operator who believes it is closed — the one outcome that must be impossible.

Not asking for auth on a published port is a warning. In local_trusted mode, if [server] bind is anything but loopback, both serve and doctor say so:

✗ auth      disabled   WARNING: no credential is required and the server is bound to
0.0.0.0:8100 — anyone who can reach this port can disable the bot
in any channel and rewrite the charter. …

everasync doctor prints the auth row last, so the answer to "is this thing exposed?" is the line left on your screen. It is a warning and not an error because 0.0.0.0 is also what a container publishes behind a private ingress — the tool cannot know which one you meant.

Every failure looks identical​

Under mode = "authenticated", every credential failure returns the same 401, with the same body and the same header:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="ever-async"

{"error":"unauthorized"}

Unknown user, wrong password, unknown token, expired session, a credential shape this provider does not speak, no credential at all — all indistinguishable.

That is deliberate. A response that distinguishes "no such user" from "wrong password" is a free account-enumeration oracle, and one that distinguishes "expired" from "invalid" lets a caller probe session lifetimes. The real reason goes to a tracing event at debug level, where the operator can see it and the caller cannot:

RUST_LOG=info,ever_async=debug everasync serve

Three details worth knowing:

  • The challenge is Bearer, never Basic. A Basic challenge makes a browser pop its own native credential dialog over the dashboard — one the dashboard can neither style nor cancel.
  • An unknown path is still a 404, not a 401. The auth layer runs only for requests that matched a route, so probing cannot map which paths exist.
  • The local provider runs one Argon2 verification even when the account does not exist, against a decoy hash, so a login for an unknown user costs the same wall-clock time as a login for a real one. Without that, timing is an enumeration oracle even when the message is identical.

Authorization failures are a different status and equally silent: a caller who presented a valid credential but holds too low a role gets

HTTP/1.1 403 Forbidden

{"error":"forbidden"}

It names neither the action nor the role that would have worked. What you were refused and what you hold are in the log, not the response.

The providers​

Three ship in the box. Pick by who is calling.

ProviderForLogin formCredential
tokenMachine callers, CI, monitoring, curl — and the quickest possible lockdownNoAuthorization: Bearer …
localHumans on the dashboard, with no external identity providerYesSession token (bearer or cookie), or Authorization: Basic …
oauthHumans who already have an account somewhere — and every SaaS deploymentNo — one button per identity providerSession token (bearer or cookie)

Only one provider is active at a time — [auth] provider names it. All three are in the default feature set, as auth-token, auth-local and auth-oauth.

token — machine callers​

No user database, no session store, no login form. That is the point: it locks down an exposed deployment in one config change, and machine callers get a credential that never expires under them.

[auth]
mode = "authenticated"
provider = "token"

[auth.providers.token]
tokens_env = "EVERASYNC_API_TOKENS" # the NAME of a variable, never the value

The tokens themselves live only in the environment:

export EVERASYNC_API_TOKENS="ci:operator:evasync_aB3…,alice:board:evasync_Zq7…:T0123|T0456"

Each entry is user_id:role:secret, with an optional fourth field: a |-separated list of workspace ids. Entries are separated by commas or whitespace (newlines included, so a multi-line Kubernetes Secret value works).

FieldRequiredMeaning
user_id✅Appears in logs and in refusal decisions. Any non-empty string
role✅viewer, operator or board — see Roles
secret✅The bearer token. May not contain :, which is why minted tokens never do
workspaces—T0123|T0456. Absent means all workspaces

Minting one:

everasync auth token --user ci --role operator
# optionally: --workspace T0123,T0456

The command prints the entry, and only the entry, on stdout — every word of explanation goes to stderr — so `everasync auth token --user ci --role operator

entry.txt` captures exactly the line you have to paste. It talks to no server and writes no file: the secret exists for one instant in one terminal, and Ever Async keeps no copy. Lose it and you mint another.

Tokens carry an evasync_ prefix so one is recognisable in a paste buffer or a secret scanner, and the body is 32 characters over a 62-character alphabet drawn from the OS CSPRNG — roughly 190 bits.

Two behaviours worth knowing before you deploy:

  • An empty list is a startup failure, not an empty allowlist. If tokens_env names a variable that is unset or blank, the server refuses to start rather than coming up rejecting everyone.
  • A malformed entry is reported by position, never by content. You get API token entry #2 is malformed (…). The entry is not echoed, because the single most likely mistake is pasting a bare secret with no user_id:role: prefix — in which case the whole entry is the secret, and error strings reach logs.

Verification is constant-time and visits every configured entry with no early exit, so response timing leaks neither the list size nor how far a guess got.

local — humans​

Locally-configured users with Argon2id password hashes, who trade a password for an opaque session token. This is the provider that gives the dashboard a login form.

[auth]
mode = "authenticated"
provider = "local"

[auth.providers.local]
users_env = "EVERASYNC_USERS"
session_ttl_secs = 604800 # optional; 7 days, clamped to 300..=2592000
# Single quotes, not double: a PHC string is full of `$`, and the shell would
# expand every one of them into an empty variable.
export EVERASYNC_USERS='alice@example.com:board:$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA
bob@example.com:viewer:$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA'

Entries are email:role:argon2-phc-hash, separated by commas or newlines. A PHC string carries its parameters as m=19456,t=2,p=1, so a comma-separated fragment containing no : is treated as a continuation of the previous entry's parameter list rather than a new user — you do not have to avoid commas.

KeyRequiredDefaultMeaning
users_env✅—Env var holding the user list
session_ttl_secs—604800 (7 days)Session lifetime. Values outside 300..=2592000 are clamped, with a warning

Emails are matched case-insensitively; a duplicate user is a startup error.

Minting a hash — never write a plaintext password anywhere:

everasync auth hash --user alice@example.com --role board
# Password: (prompts twice, without echoing)
# Confirm password:
# alice@example.com:board:$argon2id$v=19$m=19456,t=2,p=1$…$…

As with auth token, the entry goes to stdout and the commentary to stderr. If stdin is not a terminal — a pipe, a heredoc, a CI step — the password is read as one line and not confirmed. If the terminal refuses to turn echo off, the command says so rather than pretending your typing is hidden.

Argon2id with m=19456 KiB, t=2, p=1 and a fresh 16-byte OS-random salt, so the same password never produces the same string twice. Passwords must be 12 to 128 characters; the upper bound exists so an unauthenticated caller cannot ask Argon2 to do unbounded work.

Every hash is validated at startup — not Argon2, truncated, or carrying no digest are all refused while an operator is still watching the log, rather than becoming a user who can never log in.

Three credential shapes are accepted:

ShapeBehaviour
Authorization: Bearer evasess_…Resolves a session issued by login. This is what the dashboard sends
Cookie: everasync_session=evasess_…Same, accepted as an alternative for clients that prefer a cookie
Authorization: Basic <base64>Verifies the password directly and returns the principal without minting a session — so curl -u alice@example.com:… in a loop does not grow the session store

The dashboard keeps its token in localStorage and puts it in an Authorization header rather than relying on the cookie, so nothing is attached to a cross-site request automatically.

POST /api/v1/auth/login takes {"user": "…", "password": "…"} and returns the issued session — the token, the principal it authenticates as, and its expiry. The route is mounted only when the provider issues sessions, so a token-only deployment does not serve it at all.

Sessions are in-memory and per-process

The session store is a map in the server's own memory — for both the local and the oauth provider. A restart logs everyone out, and a second replica does not see the first replica's sessions.

🛑 This is now the binding constraint on replicas. It used to be shadowed by a bigger one — the nudge scheduler — but nudge delivery is claimed in the database now, so storage and nudges are both safe above one replica and sessions are not (see Self-hosting → Scaling later). If you run more than one replica, use the token provider, which is stateless; local and oauth will hand a browser a session that only one pod recognises.

Expired sessions are evicted the moment anyone presents one, and the whole store is swept on each login, so dead entries do not accumulate.

oauth — browser SSO​

The provider a SaaS deployment runs, and the one to reach for when the people using the dashboard already have a Slack, Google, GitHub, Discord or Ever Gauzy account. Nothing to mint, no passwords to store, and no user list to keep in an environment variable.

[auth]
mode = "authenticated"
provider = "oauth"

[auth.providers.oauth]
base_url = "https://app-async.ever.co" # required — callbacks are built from it
allowed_domains = ["your-team.com"] # read the warning below
session_ttl_secs = 604800 # optional; 7 days, clamped 300..=2592000

[auth.providers.oauth.slack]
client_id = "1234567890.1234567890"
client_secret_env = "EVERASYNC_SLACK_CLIENT_SECRET"

Five identity providers, each switched on by having a sub-section and off again with enabled = false inside it (which keeps the credentials). A sub-section whose name is not one of the five is a startup error, not one missing button.

Sub-sectionVerified email comes fromReports a workspace?
.slackemail_verified on the OIDC userinfo✅ the team id — the no-picker path to a tenant
.discordverified on the profile✗
.github/user/emails, which must be primary and verified✗
.googleemail_verified on the OIDC userinfo✗
.gauzyemailVerifiedAt or isEmailVerified✅ the Gauzy tenantId

Only .gauzy accepts a base_url of its own (default https://api.gauzy.co), because Ever Gauzy is the only one of the five a customer can deploy themselves; a base_url on any other section is a startup error rather than a key that quietly does nothing.

The redirect URI is where first attempts fail, and it must match byte for byte:

{[auth.providers.oauth] base_url}/api/v1/auth/oauth/{provider-id}/callback

→ Setting up SSO walks every console, scope and failure message. → Ever Gauzy SSO for the Gauzy client registration.

Four security properties worth stating explicitly:

  • An unverified email is never an identity. A missing verification flag counts as unverified, never as "probably fine" — the email is what allowed_domains and any invitation-by-email key off, so trusting an unverified one would let someone claim an address they do not control.
  • The state is single-use, capped and expires in 10 minutes. Starting a login is anonymous by definition, so without the cap anyone could grow the pending map until the process died. PKCE S256 is sent to the providers that document support (Google, Discord); Slack, GitHub and Ever Gauzy do not, and some authorization servers reject parameters they do not know.
  • The finished session is handed over in the URL fragment (/#token=…), never the query string. A fragment is never sent to a server, so the token stays out of access logs, out of Referer headers, and out of anything an intermediary records.
  • Every refusal says the same thing. Replayed state, expired state, refused code, unreachable provider, unverified email, disallowed domain — all redirect back to the dashboard with one bare #error= code. Which one it was is in the log, at debug, and nowhere else.
Ever Async never holds a provider's token-signing secret

Ever Gauzy signs every access token, in every tenant, with one global HS256 secret — so anything holding it can mint a token for any user anywhere. A provider's access token is therefore treated as opaque: it is presented back to the provider once, and whatever the provider answers is the identity. No signature check, no JWT decode (not even an unverified one "for a hint"), and no config key that accepts a signing key — a section naming one (jwt_secret, jwt_secret_env, signing_key, token_secret) fails at startup with an explanation rather than being ignored.

Two things fall out of that for free: a revoked provider token stops working here immediately instead of staying valid until it expires, and a Gauzy security incident cannot become an Ever Async one.

warning
allowed_domains is a decision, not a default

With no allowlist, any account at these providers can complete a login — everyone on earth with a Google account, not only your colleagues. Startup logs a warning when the list is empty, because which answer is right depends entirely on whether tenancy is doing its job:

  • Public SaaS — an empty list is correct. A stranger who signs up lands in their own new, empty tenant and can reach nothing but their own rows. Open registration is the product.
  • Private / single-tenant self-host — an empty list is wrong. Every successful login joins the one tenant that already holds your charter and your channel policies. Set allowed_domains = ["your-team.com"].

Matching is on the whole domain after the @, case-insensitively. Subdomains are not implied: ever.co admits neither mail.ever.co nor notever.co.

Tenant isolation​

Every principal acts inside exactly one tenant, and that tenant comes from the verified session — never from the request. No /api/v1 route reads a tenant from its path, its query string or its body, and none should ever be added: a caller who can name their tenant can name someone else's.

Where a login's tenant actually comes from, most specific first:

  1. A workspace binding. A Slack login carries the team id it came from, and an Ever Gauzy login carries its tenantId; if an installation bound that workspace to a tenant, the login lands there with no picker. First-time arrivals join as viewer — Slack asserting that someone is in team T0123 is a statement about that Slack, not a decision that they may retune the tenant's policies. An operator promotes from there.
  2. An existing membership, including one an operator created by email address before that person ever signed in. All memberships travel on the session, which is what the dashboard's switcher and switch-tenant check against.
  3. A fresh tenant, slug derived from the email domain, with the new user as its board. Colliding slugs are suffixed (acme-2), never shared.

POST /api/v1/auth/switch-tenant is the one place a tenant id travels inbound, and Principal::may_act_in — consulted against the memberships on the session this server minted — is the entire security boundary of the endpoint. On success the session is re-issued bound to the new tenant, carrying the roles held there. The old token keeps working until it expires and stays bound to the old tenant; the token is the tenant, and there is no server-side "current tenant" pointer for a request to move.

Isolation itself is enforced in the storage layer: tenant leads every primary key and every index. That is what makes open signup safe rather than reckless, and it is why the UI is not where the check lives.

→ Multi-tenancy

Roles​

Three tiers, and they are ordinal — viewer < operator < board. A check is "is your highest role at least this one", not a scope-set intersection. Giving someone board gives them everything operator has.

RoleIntent
viewerRead-only: look at status, policies, digests, metrics and the charter
operatorAlso change per-conversation policy and edit the charter
boardEverything, including triggering a delivery

Which role reaches which endpoint​

Every handler names the tier it needs and the action it is doing; the action string is what appears in the refusal log.

MethodPathLeast roleAction
GET/healthzpublic—
POST/ingress/{channel} and /ingress/{channel}/{*rest}public — signature-verified instead—
GET/api/v1/auth/modepublic—
POST/api/v1/auth/loginpublic — mounted only when the provider issues sessions—
GET/api/v1/auth/oauth/{provider}/startpublic — a login cannot require a login. Mounted only with [auth.providers.oauth]—
GET/api/v1/auth/oauth/{provider}/callbackpublic — the identity provider redirects a browser here. Same condition—
GETdashboard static filespublic — a login form has to load before anyone can log in—
GET/api/v1/auth/meany authenticated caller—
POST/api/v1/auth/switch-tenantany authenticated caller — and only into a tenant they already belong to—
GET/metricsviewermetrics.read
GET/api/v1/statusviewerstatus.read
GET/api/v1/charterviewercharter.read
PUT/api/v1/charteroperatorcharter.write
GET/api/v1/digestviewerdigest.read
POST/api/v1/digest/sendboarddigest.send
GET/api/v1/llm/modelsviewerllm.models.read
GET/api/v1/policies/{channel}/{conversation}viewerpolicy.read
PUT/api/v1/policies/{channel}/{conversation}operatorpolicy.write

The split follows the role definitions exactly: reads are viewer, the two configuration writes are operator, and the one route that causes an outbound message to a human — digest/send — is board.

switch-tenant is the odd one out because it is not gated by a role at all: any authenticated caller may call it, and what constrains them is membership. It re-issues the session bound to another tenant, carrying whatever roles they hold there — which may be higher or lower than the ones they held before. A tenant they do not belong to is a flat 403.

In local_trusted mode every row above resolves to the local board principal, so nothing 401s and nothing 403s. The table describes what changes when you set mode = "authenticated".

GET /api/v1/auth/mode is how the dashboard decides what to render before it has any credential. It reports the mode in force, the provider id, and whether login exists — never anything about a user, and never whether a given user exists. GET /api/v1/auth/me returns the principal the request resolved to, which in local_trusted is how the dashboard knows it needs to ask for nothing.

Workspace scoping​

A principal may additionally be pinned to a set of chat workspaces — the fourth field of a token entry, or --workspace on the mint command. An empty list is the wildcard: all workspaces. This is independent of the role check: a board principal scoped to T0123 is still powerless over T0456.

Scoping bites wherever a handler can resolve a workspace. Today's /api/v1 routes are keyed by channel and conversation rather than by workspace, so in practice the field is carried on the principal and checked where one is available — set it for defence in depth, but do not treat it as the primary boundary on the routes in the table above.

What stays public and why​

The allowlist is six path prefixes, matched on whole segments so /ingressive never inherits /ingress's exemption. It is deliberately short and hand-audited: every entry is a hole in the gate and has to justify itself, and it is pinned by a test so adding one means editing that test on purpose.

/ingress
/healthz
/api/v1/auth/login
/api/v1/auth/mode
/api/v1/auth/oauth/*/start
/api/v1/auth/oauth/*/callback

The * stands for exactly one non-empty segment — an OAuth provider id — and never spans a /. /api/v1/auth/oauth/*/start therefore exempts those two routes and nothing else: a route added under that prefix later has to earn its own line rather than inherit one, and /api/v1/auth/oauth/slack/link would be gated like anything else.

/ingress/* — Slack and friends​

Webhook sources are servers, not people. Slack cannot present an Ever Async credential — the only thing it carries is its own request signature. If auth gated this route, every event would answer 401, and a platform that keeps getting rejections disables the endpoint. Delivery would not degrade, it would stop.

This route is not unauthenticated, it is authenticated differently: by signature rather than by credential, and by the channel plugin rather than by the auth provider. For Slack that is the documented v0 scheme — HMAC-SHA256 over v0:{timestamp}:{raw body} under the app's signing secret, compared in constant time, with a ±300 second timestamp window that rejects replays. A request that fails is rejected before the pipeline sees it and counted in ever_async_ingress_rejected_total{reason="verification"}.

Two consequences:

  • The signing secret is a security boundary. Rotate it like any other credential.
  • Any proxy in front must forward the body unmodified — re-encoding or pretty-printing invalidates every signature. See Self-hosting → Reverse proxy / TLS.

/healthz​

Returns the literal string ok and touches nothing — no database, no plugin, no network. Kubernetes probes generally cannot carry a credential, and a 401 on a liveness probe is a restart loop that looks exactly like a crash. The answer tells an attacker nothing they could not learn by watching the port accept connections.

/api/v1/auth/mode and /api/v1/auth/login​

A caller cannot present a session before it has one, and an unauthenticated dashboard has to be able to ask whether it should render a login form at all. mode reports the mode in force, the provider id, whether login exists, and the sso_providers list — ids and labels only, i.e. which buttons this deployment offers, never anything about a user and never whether a given user exists. sso_providers is always present and is [] when no OAuth provider is configured. login verifies inside the provider, and fails exactly like every other credential failure.

The dashboard's static files are also reachable without a credential — they are served as the router's fallback, outside the auth layer, because a login form has to load before anyone can log in. The data it then asks for is what the layer guards.

/api/v1/auth/oauth/{provider}/start and /callback​

Anonymous for the plainest of reasons: a login flow cannot require a login. The caller at start is by definition anonymous — that is what they came here to stop being — and the route hands back nothing but a redirect to the identity provider, which is public knowledge.

The callback is anonymous for that reason and one more: the request is made by the provider's redirect, which carries no credential of ours and could not be given one. Neither route is unverified. start mints a single-use, capped, 10-minute state; callback refuses anything whose state does not match a login this server actually started, and redeems the code server-to-server. A successful exchange is precisely what produces the first credential.

/api/v1/auth/switch-tenant is deliberately NOT on the list — it re-issues a session, so it needs one to start with, and it is pinned out of the allowlist by the same test.

/metrics is not public​

This one catches people out, so it is worth stating flatly: /metrics requires viewer under mode = "authenticated". It is outside the CORS layer, not outside the auth layer.

The counters carry no message content, but they name every configured channel, count every conversation, and show which connectors are erroring and which LLM provider is in use — the shape of your deployment. That has no business being world-readable.

Prometheus can present a credential. Mint a viewer token and give it to the scraper:

scrape_configs:
- job_name: everasync
authorization:
type: Bearer
credentials_file: /etc/prometheus/everasync-token # or `credentials:` inline
static_configs:
- targets: ['everasync:8100']

Deploying behind a reverse proxy​

Everything in Self-hosting → Reverse proxy / TLS still applies — terminate TLS, forward the body unmodified, keep the clock right. Two additions once auth is in play.

Ingress basic auth is a real layer — but pick one​

An HTTP basic-auth gate at the ingress or proxy is a legitimate belt-and-braces control. It stops unauthenticated traffic before it reaches the process at all, and it is the right emergency measure for a deployment that has not enabled [auth] yet.

But running both the proxy gate and app auth means two prompts, and worse than that, they collide on the same header:

  • Both use Authorization. A proxy that validates basic credentials and then forwards the header upstream hands Ever Async the proxy's credentials. With the token provider a Basic credential is refused outright → 401. With the local provider it is treated as a real login attempt for a user that does not exist → also 401. Either way the app rejects a caller the proxy just accepted.
  • A proxy that consumes the header instead hides the app's credential, so every request arrives anonymous → 401 again.
  • In a browser the user gets the proxy's native basic-auth dialog first, then the dashboard's own login form. (Ever Async never adds a second native dialog — its challenge is Bearer precisely so the browser stays out of it — but the proxy's dialog is still there.)

So: choose a boundary.

If you…Then
Enable [auth] mode = "authenticated"Remove the ingress basic-auth gate. The app is the boundary, and it has roles and per-user attribution
Keep the ingress basic-auth gateStay in local_trusted and accept that the proxy is your only boundary — no roles, no attribution, one shared password

Whichever you pick, exempt /ingress/* and /healthz​

A basic-auth gate covering the whole host breaks Slack, for the same reason app auth would: the platform cannot satisfy it. A gate covering /healthz restarts your pods. Scope the gate to everything except those two prefixes, and let the signature check be the boundary on ingress.

If webhooks stop arriving right after you add a proxy gate, this is why — check ever_async_events_total and the platform's own delivery log before suspecting the signing secret.

If you are exposed right now​

A short, ordered checklist. Steps 1–4 take about ten minutes.

1. Ask the binary. doctor prints the auth row last, precisely so this is the line left on screen:

everasync doctor --config ./everasync.toml

A WARNING: no credential is required and the server is bound to … row is the answer. Confirm it from off your network:

curl -s -o /dev/null -w '%{http_code}\n' https://your-host/api/v1/status

A 200 means anonymous reads work — and since nothing distinguishes them, anonymous PUT /api/v1/charter works too.

2. Lock it down with the token provider. It needs no user store, so it is the fastest path from exposed to closed:

everasync auth token --user alice --role board
everasync auth token --user ci --role operator
everasync auth token --user prom --role viewer
[auth]
mode = "authenticated"
provider = "token"

[auth.providers.token]
tokens_env = "EVERASYNC_API_TOKENS"
export EVERASYNC_API_TOKENS="alice:board:evasync_…,ci:operator:evasync_…,prom:viewer:evasync_…"

3. Restart, then verify both directions. One of these must be 401 and the other 200 — check both, because a config typo that leaves you in local_trusted looks like success if you only test the happy path:

curl -s -o /dev/null -w 'anon  %{http_code}\n' https://your-host/api/v1/status
curl -s -o /dev/null -w 'token %{http_code}\n' \
-H "Authorization: Bearer evasync_…" https://your-host/api/v1/status

GET /api/v1/auth/mode is public and will tell you what the server thinks it is enforcing, which is the quickest way to catch a typo'd mode.

4. Confirm webhooks still arrive. /ingress/* must stay public. Re-deliver an event from the platform (Slack's "Retry" in the Event Subscriptions page) and watch ever_async_events_total move.

5. Re-point the scraper. /metrics now needs the viewer token — see above. A silently-broken scrape is easy to miss.

6. Audit what an anonymous caller could have changed. Nothing in /api/v1 returns a credential, so this is not a secret-rotation event. It is an integrity one:

  • GET /api/v1/charter — are these your rules?
  • GET /api/v1/policies/{channel}/{conversation} for every conversation you care about — is anything enabled = false that should not be, or set to a sensitivity you did not choose?
  • Ask the team whether anyone received a digest they were not expecting.

7. Decide about the proxy gate. If an ingress basic-auth stop-gap is in place, remove it now that the app enforces auth — see above. Leaving both on is the two-prompt failure mode.

8. Then do the boring thing. Put it back on a private network anyway. Auth is a boundary, not a reason to publish an internal tool.

Build dependencies and remaining image parser advisories​

The application image runs the Rust server and serves the built dashboard. The documentation image serves static files through nginx. Neither final image includes the Node.js build tools or their node_modules. The Docker builders and CI install the reviewed pnpm-lock.yaml with a frozen lockfile.

The build graph pins fast-uri 3.1.6, nanoid 3.3.18, and qs 6.16.0 to their patched releases. These packages enter through Docusaurus's webpack tooling or Vite/PostCSS, rather than the Rust API's URL handling, authentication, or chat ingress. Dependency scanners may call Docusaurus dependencies "runtime" because they are regular package dependencies; the deployed image's contents and the calling code determine the actual exposure.

As checked on 7 September 2026, two image-size advisories remain open: JXL/HEIF parsing and ICNS parsing. The published latest release is still 2.0.2, and neither advisory lists a patched release. Upgrading Docusaurus alone does not resolve them.

Docusaurus's Markdown image transform calls imageSizeFromFile on local documentation images during a build. A specially crafted image can cause an infinite loop in that builder. Repository images are therefore trusted build inputs that need review, including images supplied in a contribution. An untrusted contribution could stall its documentation build; a visitor's HTTP request or chat attachment does not invoke this parser in either shipped image. This limits exposure but does not fix the package or justify dismissing its alerts. Keep the advisories visible and adopt a reviewed upstream repair when one is published.

Reporting a vulnerability​

Email security@ever.co with the affected component, reproduction steps, and impact. Do not open a public GitHub issue. Contributors with access can see SECURITY.md for the disclosure process and what is in scope.