Mobile Credentials & Vault
Vendor device farms (BrowserStack, Sauce Labs) require API credentials. The platform stores these once in the environment credential vault — encrypted at rest with AES-GCM — and references them from mobile apps by tag. No secrets in JSON.
This page covers two distinct credentials — don't confuse them:
- Vendor device-farm creds (BrowserStack/Sauce API key) — how we reach the device. Covered below.
- App-under-test login (the username/password your test types into the app) — covered in the next section.
App-under-test login — resolve from env properties, never inline
The login your test performs (e.g. Swag Labs standard_user / secret_sauce) must not be written
inline in the test YAML — that stores it cleartext in mobile_tests.yaml_content. Instead reference an
environment property, exactly like an API scenario references {{propKey}}:
steps:
- action: type
selector: accessibility:test-Username
value: "{{appUsername}}" # ← env property, not a literal
- action: type
selector: accessibility:test-Password
value: "{{appPassword}}" # ← env property whose value may be {{secrets.X}}
Set the property once on the environment (its value can itself embed a {{secrets.NAME}} reference
resolved from the K8s secret store):
# property holds the secret reference; the raw password lives only in K8s
POST /api/environments/1/variables
[{ "key": "appPassword", "value": "{{secrets.SWAG_PASSWORD}}", "secret": true }]
At run time the mobile-test-agent fetches the resolved YAML
(GET /api/mobile-tests/{id}/resolved-yaml) — {{appUsername}}/{{appPassword}} become the real
values just-in-time, resolved by the same EnvironmentPropertyResolver API scenarios use. The
resolved secret is never persisted, and the editor's /yaml endpoint still returns the raw
{{...}} refs (secrets never reach the authoring UI). The environment is derived from the mobile
app's linked target, so the app must be linked to a target/environment for resolution to occur.
See also the test data section in YAML format.
Auto-login: login_config + credentialTag (vault-backed)
Instead of authoring login steps in every test, a mobile app can carry a login_config — the
username/password selectors plus a credentialTag pointing at the environment credential
vault. The agent performs the login before your test steps run (both on test runs and crawls),
using credentials the backend resolved from the vault at dispatch — the agent never touches the vault:
PUT /api/mobile-apps/{id}
{
"loginConfig": {
"enabled": true,
"usernameSelector": "accessibility:test-Username",
"passwordSelector": "accessibility:test-Password",
"loginButtonSelector": "accessibility:test-LOGIN",
"credentialTag": "swag-user" // ← env vault entry; no username/password here
}
}
- Resolution order for the login credential:
credentialTagon the execute request →login_config.credentialTag→ the environment's default credential. SamecredentialTag → vaultchain API and UI tests use — one resolver, every surface. - Inline
username/passwordinlogin_configare deprecated. They still work (back-compat) but the vault wins when both are present, and any inlinepasswordis AES-GCM encrypted at rest (sameAuthConfigEntityListeneras every other auth surface). - If the test's YAML references
{{username}}/{{password}}and no credential resolves (no tag, no env default), the run is rejected at dispatch with a 400 naming the fix — instead of typing literal{{placeholders}}into the login form. - Authoring guardrail: typing a literal value into a password-shaped selector in mobile test YAML
produces a validation warning (same rule as UI tests) steering you to
credentialTagor a{{variable}}.
The model
Environment
│
└─ auth_config (jsonb, AES-GCM encrypted)
│
├─ defaultCredentialTag: "prod-browserstack" ← env-level default
│
└─ credentials: [
{
tag: "prod-browserstack",
username: "alice@company.com",
password: "ABCDEF...", ← BrowserStack access key
isDefault: true
},
{
tag: "dev-saucelabs",
username: "company-dev",
password: "12345...",
isDefault: false
}
]
Mobile App
│
├─ cloud_provider: "BROWSERSTACK"
└─ cloud_config: { "credentialTag": "prod-browserstack", "deviceName": "Pixel 7" }
At job time the agent calls a vault-resolution endpoint with the mobile app id, the backend walks MobileApp → EnvironmentTarget → Environment → vault, finds the credential by tag, and returns the inline userName + accessKey to the agent.
Adding a vendor credential
In the environment's auth config, add a credential set:
{
"defaultCredentialTag": "prod-browserstack",
"credentials": [
{
"tag": "prod-browserstack",
"username": "your-bs-username",
"password": "your-bs-access-key",
"isDefault": true
}
]
}
Fields:
- tag — referenced from
cloud_config.credentialTag. Pick anything memorable (prod-bs,qa-saucelabs,team-a-bs). - username — BrowserStack/Sauce Labs username (typically your dashboard login email).
- password — the vendor access key. Field is named
passwordfor compatibility with the rest of the vault, but for vendor APIs it's the access key. - isDefault — optional. When true, any app that doesn't specify
credentialTaguses this one.
The password is AES-GCM encrypted before being written to the DB via the AuthConfigEntityListener.
Reference patterns
1. Env property → Kubernetes secret (recommended)
This is the #587 model: the app references an
environment property, and the property's value embeds a {{secrets.NAME}} reference resolved from
a per-environment Kubernetes Secret. The vendor accessKey — a password — is read from env
properties exactly like an API scenario reads {{propKey}}, and the key never lives in our
database.
{
"userName": "your-bs-username",
"accessKey": "{{BROWSERSTACK_ACCESS_KEY}}",
"deviceName": "Google Pixel 7"
}
Set up once (SUPER_ADMIN):
# 1. Put the real key in the environment's Kubernetes Secret (write-only; value never read back)
set_environment_secret environment=development key=BROWSERSTACK_ACCESS_KEY value=<real-key>
# 2. Add an env property whose VALUE is the secret reference
POST /api/environments/1/variables
[{ "key": "BROWSERSTACK_ACCESS_KEY", "value": "{{secrets.BROWSERSTACK_ACCESS_KEY}}", "isSecret": true }]
# 3. Point cloud_config.accessKey at the property
{ "accessKey": "{{BROWSERSTACK_ACCESS_KEY}}", "userName": "your-bs-username", "deviceName": "Google Pixel 7" }
At job time the backend resolves cloud_config through the shared EnvironmentPropertyResolver:
{{BROWSERSTACK_ACCESS_KEY}} → env property → {{secrets.BROWSERSTACK_ACCESS_KEY}} → the K8s Secret.
userName is an account identifier, not a secret, so it stays as a plain value (put it behind a
property too if you prefer). A direct {{secrets.X}} in cloud_config still resolves (escape hatch),
but referencing a property is the intended model — one source of truth, consistent across API / UI /
mobile.
2. Explicit vault tag
{
"credentialTag": "prod-browserstack",
"deviceName": "Google Pixel 7"
}
The legacy credential-vault lane (values AES-GCM encrypted in our DB). Wins over any default. Use when an app needs different credentials from the env default (e.g., a customer account vs. an internal account). Prefer pattern 1 for new apps — it keeps the key out of our database entirely.
3. Default credential (implicit)
{
"deviceName": "Google Pixel 7"
}
No credentialTag, no inline creds. Agent uses the env's default credential — either:
- The credential matching
environment.defaultCredentialTag, OR - Any credential with
isDefault: true
Most teams set one default per env so 90% of mobile apps don't need credential boilerplate.
4. Inline (legacy, back-compat)
{
"userName": "your-bs-username",
"accessKey": "your-bs-access-key",
"deviceName": "Google Pixel 7"
}
Passes through unchanged. The vault is bypassed. Avoid for new apps — secrets in JSON are harder to rotate and surface in any UI roundtrip that re-fetches the mobile app.
Precedence
Resolution runs in this order — {{propKey}} / {{secrets.X}} references are substituted first,
before the inline/tag/default decision, so a resolved property yields a real inline value:
| What you set | What the agent receives |
|---|---|
accessKey: "{{propKey}}" / "{{secrets.X}}" | Resolved from env property → K8s secret, then treated as inline |
Inline userName + accessKey (literals) | Inline (no vault lookup) |
credentialTag + matching vault entry | Resolved from vault |
credentialTag + missing vault entry | Hard error — names the missing tag, lists available ones |
| No tag, no inline, but env has a default | Resolved from env default |
| No tag, no inline, no env default | Pass through; vendor validation surfaces "missing userName" |
{{propKey}} / {{secrets.X}} but app has no linked environment | Hard error — link the app to an environment target first |
Rotating a credential
Update the vault entry's password field. Every mobile app pointing at that tag picks up the change on the next job — no app-level edit needed. Old in-flight tests continue with the old credential since the agent caches it for the duration of the test.
To force immediate rotation across all running tests: remove the old credential set, the next vault-resolution call fails fast, the agent surfaces an error in Job History, operator fixes.
Multiple environments
The vault is per-environment, so:
| Environment | Credential tag | Use case |
|---|---|---|
| Development | dev-browserstack | shared low-volume tier |
| Staging | staging-browserstack | mid-volume parallel tests |
| Production | prod-browserstack | release-day matrix |
Same mobile app can reference different tags depending on which environment it's targeting. The agent resolves against the environment that owns the EnvironmentTarget linked to the test job.
Who can see the vendor credential
Only the test agents. Not you, not an admin.
The device-farm account has to be handed over in the clear to something — an agent cannot open a
BrowserStack session without it. So it is released on identity rather than on which endpoint you
happen to call: the agents' own service account receives the real value, and every other caller —
viewer, analyst, admin — gets ***.
This is the fix for a real leak. GET /api/applications/{id}/mobile-config resolves the account so
the agent can use it, and for a long time it handed the same body to anybody holding any login. A live
vendor key was readable straight off the API by a read-only user.
What this means for you day to day:
- Seeing
***is correct, at any role. There is no role that shows it. If you need to change the key, set a new one; you cannot read the old one back. - An agent that gets
***cannot log in. If a farm rejects the session with a 401, check whether the agent is authenticating as its own service account rather than as a person.
Secrets at runtime
After resolution, the inline creds exist only:
- In-memory on the backend (during the single resolve call)
- Over the wire from backend → agent (HTTPS in production)
- In-memory on the agent (during driver creation)
They are not stored on disk, not in the agent's job-claim cache, not in MinIO. Each job re-resolves from the vault.
Audit trail
The vault already audits reads via CredentialAuditService. Every time a job resolves a tag, an audit entry is written: who (agent id) + when + which tag + which environment. View via the existing audit log endpoint or the Audit Logs UI page.
What's NOT in the vault yet
- Per-app credential overrides — today's pattern is env-scoped. Some teams might want per-app credentials (e.g., team-A uses tag X, team-B uses tag Y in the same env). Today the workaround is multiple environments. Per-app tags are on the roadmap.
- Credential rotation hooks — no UI for time-bound rotation policies. Manual rotation only.
Related
- Device farms — where credentials get used (BrowserStack / Sauce)
- Self-hosted — T2 doesn't need vendor credentials at all
- API credentials — same vault, different consumers