How mooflair keeps your account, your credentials and your browsers safe, and where each secret lives.
Where secrets live
| Secret | Stored | Reaches the page? | Reaches the engine? |
|---|---|---|---|
| Account passwords, 2FA secrets | mooflair database, encrypted | Never | In what the engine gets for a run, for the nodes that need them |
| AI keys | mooflair database, encrypted | Never (only a short hint of the key) | In what the engine gets for a run |
| Credentials (values, OAuth2 tokens) | mooflair database, encrypted | Never (a hint of each password field) | In what the engine gets for a run, resolved (auth headers and query); mid-run through /api/run/credential/token |
| Secret variables | mooflair database, encrypted | Hidden after saving | In what the engine gets for a run |
ENGINE_SECRET | mooflair (engine record) and engine env | Never | — |
Secrets at rest
Account passwords and 2FA seeds, proxy passwords, AI keys, credential values and OAuth2 tokens, and variable values are encrypted in MongoDB with AES-256-GCM, stored as v<version>:<iv>:<tag>:<data>. Reading them through the app opens them; nothing else in the database holds them in plain text.
- Keys come from the environment, the one setting kept out of
configuration.json(a key must never sit beside what it protects):ENCRYPTION_KEY_<version>(32 bytes, base64;openssl rand -base64 32) andENCRYPTION_KEY_VERSION, the one new values use. - Rotating: add
ENCRYPTION_KEY_2, setENCRYPTION_KEY_VERSION=2and restart. Old values keep opening with key 1; then in Admin › System › Jobs press Re-seal secrets with the current key: thesecret/rotatejob re-seals every value on an older key (accounts, proxies, AI keys, variables). When it is done, key 1 can go. - Back up the keys with the database: without them the secrets can't be read.
Who can use a secret
Each account, AI key and variable has Who can use it: everyone in the workspace (default), admins and me, only me, or people I pick. Creators, owners and admins can always use it.
- Studio's pickers only list the datasets you may use.
- What a run needs is checked again when it starts (
/api/internal/run), for the person behind the run: you for test runs, the campaign's creator for campaign runs, the flow's creator for schedule, webhook and form runs. A node using a dataset that person can't use stops the run with "ask its owner to share it". - Variables someone can't use are left out of
{{ vars }}for their runs.
Secrets in logs
The engine replaces every secret the run carries (passwords, 2FA secrets, keys, tokens) with •••• in log lines and their context before they're saved or sent to a page.
What a run gets
- The engine asks mooflair's server for it when the run starts (
POST /api/internal/run), server to server withENGINE_SECRET. The page never receives it and can't read or change it. - mooflair answers an engine only for the campaigns, flows and browsers placed on that engine; the person behind the run is mooflair's to decide (the page's start only names who pressed Run, after its own access check).
- The internal API is for the engine process only: node code runs in the browser and worker containers, which never get
ENGINE_SECRET; a node reaches mooflair with its run's own token (/api/run/*, scoped to the run's workspace, campaign and test mode). - Never written to the engine's database: a run's context and its secrets live in memory while the run is queued or going.
Tokens
| Token | Signed by | Lifetime | Scope |
|---|---|---|---|
| Workspace token (page → engine) | mooflair's secret.engine.key, ES256 (every engine checks it with the public key in its MOOFLAIR_KEY) | 1 hour | That workspace's runs, browsers, live links (signed only after mooflair checked the person's role) |
| Campaign token (dashboard → engine) | secret.engine.key, ES256, campaign claim | 1 hour, renewed by the page | One campaign's runs, logs, stats, socket, and the browsers its runs use |
| Flow token (nodes → mooflair) | secret.jwt (configuration.json) | 7 days | That user's tables (its campaign's group, test rows kept apart), caps, cache, notifications |
| Live link token | ENGINE_SECRET, HMAC-SHA256 | 1 hour from Studio | One browser, view or control; revoked by bumping live.version |
| Webhook / Form token | random, 24–64 chars | until rotated (New URL) | Starts one published flow |
API key (moo_…) | random, 32 bytes; stored as SHA-256 | its expiry, or until deleted or its creator leaves | One workspace, as its role (Admin or Member), rate limited per key () |
Webhook signing secret (whsec_…) | random, 24 bytes; sealed at rest | until rolled | Signs outgoing webhooks: HMAC-SHA256(secret, "<t>.<body>") in X-Mooflair-Signature |
Engine isolation
- CORS allows only
ENGINE_CORS_ORIGIN; run sockets also check theOrigin. - Each browser container has its own Docker network shared only with the engine and NATS; browsers can't reach each other.
- The container is never exposed: the live view goes through the engine's proxy, and view links log in as KasmVNC's read-only user.
- A user can only open live links and runs for their own browsers.
Network guards in nodes
- HTTP Request and Open page refuse private addresses (SSRF protection: localhost, private ranges, link-local).
- HTTP Request sends only
GETin test runs. - Notify only posts to real Discord (
discord.com) and Slack (hooks.slack.com) webhook URLs over HTTPS.
Good practice
- Use a long random
ENGINE_SECRET, different per environment.secret.jwtis made on first start (64 random bytes) and kept inconfiguration.json: back it up with the file. - Put the engine behind HTTPS (a reverse proxy such as httpd or nginx in front of
127.0.0.1:4000). - Give each automated platform account a strong password and its 2FA secret.
- Rotate a webhook URL (New URL) if it leaks.
FAQ
Can someone with a live link control my browser?
Only with a control link, and only until it expires (1 hour from Studio) or you revoke links (DELETE /browser/<id>/live).
Sign-in protection
-
Rate limit: sign-in is counted per IP and per account, sign-up per IP (default 5 attempts per 15 minutes, set in the ); a right password clears the account's count. It stops brute force while captcha is off. Trade-off: someone can fill an account's count to slow its owner down, so the IP count and the captcha are the real guards.
-
Closed registration:
authentication.sign.up.enabled: falsemakes the server refuse sign-ups. -
reCAPTCHA Enterprise (off by default). To turn it on:
- In Google Cloud, create a score-based reCAPTCHA Enterprise key for your domains (add
localhostfor development) and an API key limited to the reCAPTCHA Enterprise API. - In Admin → Configuration, enter the site key, project ID and API key, then switch captcha on. The API key is stored in
library/configuration.jsonand never sent to the page (leave it blank when saving to keep the saved one).
- In Google Cloud, create a score-based reCAPTCHA Enterprise key for your domains (add
-
The page asks for a token with the action
SIGN_INorSIGN_UPat submit time (tokens last about 2 minutes). The server creates an assessment and accepts it only if the token is valid, was made for that action, and scores at least the threshold. When captcha is on but misconfigured, or Google can't be reached, the request is refused and logged. -
Two-factor sign-in (authenticator app, RFC 6238) with one-time recovery codes; workspaces on Business can require it. See .
-
Public triggers: a webhook takes at most 60 calls a minute and 120 per IP (429 with Retry-After); a form at most 10 answers per IP in 10 minutes and 300 an hour, with the captcha when it's on.
-
Password reset and sign-up codes: one-time codes (6 digits, 10 minutes, 5 tries, 60 s between sends, 5 an hour per target and per IP) stored as a peppered hash; the reset answers the same for unknown accounts and ends every session. See .