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 it | PUT /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 author | PUT /api/v1/charter |
| Make the product message your team — a real digest, delivered to real Slack DMs and every configured notify provider | POST /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
*_envrule), never in the config file and never in a response body.statusreturns 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.
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
| Mode | Credential required? | Who is the caller? |
|---|---|---|
local_trusted (default) | No | Every request is the local board principal (user_id = "local"), so every handler's role check passes |
authenticated | Yes, on every route outside the public allowlist | Whoever 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_trustedthe 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, neverBasic. ABasicchallenge 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
localprovider 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.
| Provider | For | Login form | Credential |
|---|---|---|---|
token | Machine callers, CI, monitoring, curl — and the quickest possible lockdown | No | Authorization: Bearer … |
local | Humans on the dashboard, with no external identity provider | Yes | Session token (bearer or cookie), or Authorization: Basic … |
oauth | Humans who already have an account somewhere — and every SaaS deployment | No — one button per identity provider | Session 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).
| Field | Required | Meaning |
|---|---|---|
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_envnames 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 nouser_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.
| Key | Required | Default | Meaning |
|---|---|---|---|
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:
| Shape | Behaviour |
|---|---|
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.
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-section | Verified email comes from | Reports a workspace? |
|---|---|---|
.slack | email_verified on the OIDC userinfo | ✅ the team id — the no-picker path to a tenant |
.discord | verified on the profile | ✗ |
.github | /user/emails, which must be primary and verified | ✗ |
.google | email_verified on the OIDC userinfo | ✗ |
.gauzy | emailVerifiedAt 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_domainsand any invitation-by-email key off, so trusting an unverified one would let someone claim an address they do not control. - The
stateis 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. PKCES256is 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 ofRefererheaders, 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, atdebug, and nowhere else.
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.
allowed_domains is a decision, not a defaultWith 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:
- 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 asviewer— Slack asserting that someone is in teamT0123is a statement about that Slack, not a decision that they may retune the tenant's policies. An operator promotes from there. - 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-tenantcheck against. - 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.
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.
| Role | Intent |
|---|---|
viewer | Read-only: look at status, policies, digests, metrics and the charter |
operator | Also change per-conversation policy and edit the charter |
board | Everything, 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.
| Method | Path | Least role | Action |
|---|---|---|---|
GET | /healthz | public | — |
POST | /ingress/{channel} and /ingress/{channel}/{*rest} | public — signature-verified instead | — |
GET | /api/v1/auth/mode | public | — |
POST | /api/v1/auth/login | public — mounted only when the provider issues sessions | — |
GET | /api/v1/auth/oauth/{provider}/start | public — a login cannot require a login. Mounted only with [auth.providers.oauth] | — |
GET | /api/v1/auth/oauth/{provider}/callback | public — the identity provider redirects a browser here. Same condition | — |
GET | dashboard static files | public — a login form has to load before anyone can log in | — |
GET | /api/v1/auth/me | any authenticated caller | — |
POST | /api/v1/auth/switch-tenant | any authenticated caller — and only into a tenant they already belong to | — |
GET | /metrics | viewer | metrics.read |
GET | /api/v1/status | viewer | status.read |
GET | /api/v1/charter | viewer | charter.read |
PUT | /api/v1/charter | operator | charter.write |
GET | /api/v1/digest | viewer | digest.read |
POST | /api/v1/digest/send | board | digest.send |
GET | /api/v1/llm/models | viewer | llm.models.read |
GET | /api/v1/policies/{channel}/{conversation} | viewer | policy.read |
PUT | /api/v1/policies/{channel}/{conversation} | operator | policy.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 thetokenprovider aBasiccredential is refused outright → 401. With thelocalprovider 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
Bearerprecisely 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 gate | Stay 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 anythingenabled = falsethat 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.