Skip to main content

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:

  1. Vendor device-farm creds (BrowserStack/Sauce API key) — how we reach the device. Covered below.
  2. 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: credentialTag on the execute request → login_config.credentialTag → the environment's default credential. Same credentialTag → vault chain API and UI tests use — one resolver, every surface.
  • Inline username/password in login_config are deprecated. They still work (back-compat) but the vault wins when both are present, and any inline password is AES-GCM encrypted at rest (same AuthConfigEntityListener as 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 credentialTag or 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 password for 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 credentialTag uses this one.

The password is AES-GCM encrypted before being written to the DB via the AuthConfigEntityListener.

Reference patterns

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 setWhat 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 entryResolved from vault
credentialTag + missing vault entryHard error — names the missing tag, lists available ones
No tag, no inline, but env has a defaultResolved from env default
No tag, no inline, no env defaultPass through; vendor validation surfaces "missing userName"
{{propKey}} / {{secrets.X}} but app has no linked environmentHard 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:

EnvironmentCredential tagUse case
Developmentdev-browserstackshared low-volume tier
Stagingstaging-browserstackmid-volume parallel tests
Productionprod-browserstackrelease-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.