Skip to main content

Authentication, Proxy and TLS

New in 1.1.0. Load-test requests can carry Basic, Bearer or OAuth2 client-credentials authentication, set in code with LoadAuth. The network settings, an HTTP proxy and TLS trust and key stores, are set in testfly.yml under loadtest.proxy and loadtest.tls. Everything on this page works on both engines; the differences are listed at the end. The design is recorded in docs/adr/0008-load-test-auth-proxy-tls.md (ADR 0008) in the repository.

Never write a secret into code or YAML

Read passwords, tokens and client secrets from the environment: System.getenv("NAME") in Java and ${NAME} placeholders in testfly.yml. The examples on this page do this throughout.


1. Authentication​

LoadAuth is an immutable value. Set it on a scenario with auth(..), which makes it the default of every step, or on a step to override the scenario.

FactorySendsWhere
LoadAuth.basic(user, password)Authorization: Basic base64(user:password)scenario or step
LoadAuth.bearer(token)Authorization: Bearer <token>scenario or step
LoadAuth.oauth2ClientCredentials(tokenUrl, clientId, clientSecret)Authorization: Bearer <token> with one token per run (see OAuth2)scenario only
LoadAuth.fromConfig(name)the api.auth.<name> entry of testfly.yml (types basic, bearer, oauth2)scenario or step (not an oauth2 entry on a step)
LoadAuth.none()no authenticationstep (noAuth())

Shortcuts: LoadScenario.basicAuth(user, password), LoadScenario.oauth2ClientCredentials(tokenUrl, clientId, clientSecret, scope) (a null scope sends none), LoadStep.basicAuth(user, password) and LoadStep.noAuth(). auth() without an argument returns what is set, or null.

toString() of a LoadAuth shows the kind, the user name or client id and the token URL without its query, and *** for every password, token and secret.

Basic and Bearer​

TestFly builds the Basic header itself on both engines, from the UTF-8 bytes of user:password (RFC 7617), so both engines send the same bytes. ${var} placeholders in the user, password or token are resolved for each request from feeder columns or extracted variables, so every virtual user can log in with its own account:

import io.testfly.loadtest.BaseLoadTest;
import io.testfly.loadtest.LoadAuth;
import io.testfly.loadtest.LoadTestFeeder;
import org.testng.annotations.Test;

public class OrdersAuthLoadTest extends BaseLoadTest {
@Test
public void ordersPerUser() {
loadScenario("Orders")
.feed(LoadTestFeeder.csv("testdata/users.csv")) // columns: user,password
.basicAuth("${user}", "${password}") // the default of every step
.step("List orders").get("/orders").and()
.step("Health").get("/health").noAuth().and() // sent without credentials
.step("Admin stats").get("/admin/stats")
.auth(LoadAuth.bearer(System.getenv("ADMIN_TOKEN"))).and()
.run().assertErrorRateBelow(0.01);
}
}

If a credential still holds an unresolved ${var} when the request is built, for example because a CSV row lacks the column, the request is not sent and counts as KO with status -1. The run goes on. A user name that contains : after resolution, or a resolved value with a control character (anything below 0x20 except tab, or DEL), is KO -1 the same way. This blocks header injection, and the value never appears in a message.

Precedence​

The most specific setting wins:

The step hasThe request sends
auth(..) or basicAuth(..)the step's authentication
noAuth()no authentication
header("Authorization", ..) and no step auththat header; the scenario's auth is not added
auth(..) and header("Authorization", ..)rejected before the run (IllegalArgumentException)
none of thesethe scenario's auth(..), if any

OAuth2 client credentials​

import io.testfly.loadtest.BaseLoadTest;
import io.testfly.loadtest.LoadAuth;
import org.testng.annotations.Test;

public class CatalogOAuth2LoadTest extends BaseLoadTest {
@Test
public void searchWithServiceToken() {
loadScenario("Catalog search")
.auth(LoadAuth.oauth2ClientCredentials(
"https://auth.example.com/oauth2/token",
System.getenv("CATALOG_CLIENT_ID"),
System.getenv("CATALOG_CLIENT_SECRET"))
.scope("catalog.read")
.clientSecretBasic()) // optional; default is client_secret_post
.step("Search").get("/catalog?q=shoes").and()
.run().assertErrorRateBelow(0.01);
}
}
  • One token per run. It is shared by every virtual user. In a simulation, scenarios with the same token URL, client id, scope and client authentication share one token.
  • Fetched before the load. The first token is requested after validation and before any load request. On Gatling this happens in the test JVM, before a run slot, a run directory or a child JVM. If it fails, run() throws and no load request is sent. The message has the token URL without query or user info, the HTTP status and the OAuth2 error code, for example [LoadTest] OAuth2 token request to https://auth.example.com/oauth2/token failed: HTTP 401 (error=invalid_client). It never contains the response body, error_description, the client id, the secret or a token.
  • Refreshed in the background. The token is refreshed expires_in − min(60 s, expires_in / 5) after it was obtained, at least 1 s later. A missing expires_in, or one that is 0 or negative, counts as 3600 s. Requests never wait for a refresh. A failed refresh is retried with a backoff from 250 ms up to 2 s, and the old token stays in use until it really expires.
  • The run aborts if the refresh fails. When no valid token is left after the real expiry, the run is aborted with IllegalStateException (… aborted: OAuth2 token refresh failed for <tokenUrl>: HTTP <code>) on both engines. It is not reported as KO requests.
  • The request. POST to the token URL, form-encoded grant_type=client_credentials plus scope when set. The client authenticates with client_id and client_secret in the form (client_secret_post), or with an Authorization: Basic header after .clientSecretBasic(). Only HTTP 200 with a non-blank access_token is accepted; a token_type other than bearer (any case) is rejected; a refresh_token is ignored. The request uses loadtest.proxy, loadtest.tls and requestTimeoutSeconds.
  • Not a variable. The token is not exposed as ${access_token} or any other variable. For an unusual placement (a custom header, a query parameter), request the token yourself with a step and extract(..), as in the multi-step journey.
  • Not in the metrics. Token requests are not counted in LoadTestMetrics: not in the totals, the steps, SLAs or baselines. Each run logs one INFO line instead: [LoadTest] '<scenario>': OAuth2 token requests=<n> refreshFailures=<m>.
  • Scenario level only. LoadStep.auth(..) with OAuth2 client credentials throws IllegalArgumentException.

Reusing api.auth with fromConfig​

LoadAuth.fromConfig(name) uses the api.auth.<name> entry of testfly.yml, the same block as API tests (see Authentication). Only the types basic, bearer and oauth2 are supported; any other type fails before the run with a message listing these three. The entry is resolved when the run is validated, and ${ENV} placeholders are resolved as usual. A Basic or Bearer value that is still a whole ${name} is then resolved per request as a load-test variable; an oauth2 value that is still a placeholder is rejected.

api:
auth:
catalogService:
type: oauth2
tokenUrl: https://auth.example.com/oauth2/token
clientId: ${CATALOG_CLIENT_ID}
clientSecret: ${CATALOG_CLIENT_SECRET}
loadScenario("Catalog search")
.auth(LoadAuth.fromConfig("catalogService").scope("catalog.read"))
.step("Search").get("/catalog?q=shoes").and()
.run();

The api.auth entry has no scope, so set it with .scope(..). .scope(..) and .clientSecretBasic() on an entry that is not oauth2 are rejected. @UseAuth does not apply to load tests: use fromConfig.

Redirects​

Credentials are never sent to another origin when a step follows a redirect. On Gatling, a redirect hop to the same origin also goes without credentials; the JDK engine keeps them on a same-origin hop.

Validation​

These are rejected with IllegalArgumentException before any request. The messages name keys and variables, never a value.

  • Basic: a null user or password, a blank user, a literal user containing :, and #{ in either value (Gatling-only syntax; use ${var}).
  • Bearer: a blank token, or one containing #{.
  • OAuth2: a token URL that is not an absolute http or https URL, or that has user info or a fragment; a blank client id or secret; ${ or #{ in the token URL, client id, client secret or scope (the token is requested once per run, not per virtual user); a scope that is not RFC 6749 scope tokens separated by single spaces; OAuth2 on a step.
  • fromConfig: a name that is not in api.auth, or an unsupported type.
  • Both on one step: a step with its own auth and an Authorization header (name in any case).
  • Credentials in the URL: a scenario baseUrl with user info (https://user:pass@host) when any auth is configured. Use basicAuth(..).

2. Proxy (loadtest.proxy)​

loadtest:
proxy:
host: proxy.corp.example
port: 3128
username: ${LOAD_PROXY_USER}
password: ${LOAD_PROXY_PASSWORD}
nonProxyHosts: [localhost, "*.internal.example"]
KeyTypeDefaultDescription
proxy.hoststringunsetProxy host name or IP, without scheme, path or @. Unset means no configured proxy.
proxy.portint01 to 65535; required with a host.
proxy.usernamestringunsetProxy user. Set both username and password, or neither.
proxy.passwordstringunsetProxy password. Use a ${ENV} placeholder.
proxy.nonProxyHostslistemptyHosts reached directly. * is a wildcard, as in api.proxy.
  • An explicit proxy wins. With a host set, load requests and OAuth2 token requests go through it on both engines, and the JVM proxy properties (http.proxyHost, https.proxyHost, http.nonProxyHosts) are ignored.
  • Proxy credentials go only to the proxy. They are never sent to the target. Both engines send them preemptively: as Proxy-Authorization on plain-HTTP requests and on the CONNECT request that opens an HTTPS tunnel.
  • No fallback to api.proxy. loadtest.proxy and api.proxy are separate, so load is never routed through a proxy you configured for API tests.
  • Unset. Without loadtest.proxy.host, nothing changes from earlier versions: the JDK engine honours the JVM proxy properties, and TestFly sets no proxy on Gatling.
  • Wildcards on Gatling. Gatling matches bypass hosts exactly. TestFly passes the literal nonProxyHosts entries plus every known host that matches a wildcard: the scenario baseUrl, the token URL and literal absolute step URLs. A host that is only known at run time, such as https://${host}/x, matches a wildcard on the JDK engine only.
Known JDK limit with proxy credentials and HTTPS
  • The JDK refuses Basic authentication for a CONNECT tunnel unless the JVM-wide property jdk.http.auth.tunneling.disabledSchemes allows it (by default it disables Basic, and the JDK then strips the Proxy-Authorization header from the CONNECT). TestFly does not change that property. When proxy credentials and an HTTPS target come together, TestFly logs one WARN per run. This affects the JDK engine and OAuth2 token requests on both engines.

These are rejected with IllegalArgumentException before any request: a host with a scheme, /, @ or whitespace; a port outside 1..65535; only one of username and password; port, username or password without a host; a blank nonProxyHosts entry; a password that is still a whole ${NAME} placeholder (loadtest.proxy.password references unset variable NAME).


3. TLS (loadtest.tls)​

loadtest:
tls:
trustStore:
path: certs/load-trust.p12
password: ${LOAD_TRUSTSTORE_PASSWORD} # type defaults to PKCS12
keyStore: # mTLS client certificate
path: certs/load-client.p12
type: PKCS12
password: ${LOAD_KEYSTORE_PASSWORD}
KeyTypeDefaultDescription
tls.trustStore.pathstringunsetTrust store file. It replaces the default trust.
tls.trustStore.typestringPKCS12PKCS12 or JKS.
tls.trustStore.passwordstringunsetStore password. Use a ${ENV} placeholder.
tls.keyStore.pathstringunsetClient key store for mTLS.
tls.keyStore.typestringPKCS12PKCS12 or JKS.
tls.keyStore.passwordstringunsetStore password; the private key must open with the same password.
tls.insecureTrustAllbooleanfalseTrust any server certificate. The hostname is still verified.
  • Verification. With a trustStore or a keyStore set, both engines verify the certificate chain and the hostname.
  • A trust store replaces the default trust. The CA of the OAuth2 token URL must be in it too, because token requests use the same settings.
  • A keyStore without a trustStore uses the JVM default trust.
  • Key password. The private key must open with the store password, because Gatling takes a single password. TestFly checks this before the run on both engines. Choosing a key by alias is not supported.
  • Checked before the run. The stores are loaded before any request. A missing file, a wrong password, an unknown type, a key store without a private key and a trust store without a certificate fail with IllegalArgumentException that names the key and the path, never the password. For example: [LoadTest] loadtest.tls.keyStore: cannot load 'certs/load-client.p12' as PKCS12 (wrong password or corrupt file).
  • insecureTrustAll: true trusts any server certificate but still checks the hostname, on both engines. It logs a WARN once per run ([LoadTest] loadtest.tls.insecureTrustAll: server certificates are NOT verified (hostname verification stays on)). It cannot be combined with trustStore. Use it only against test environments.
  • No fallback to api.ssl. As with the proxy, loadtest.tls is separate.
Gatling without loadtest.tls does not verify certificates

When loadtest.tls is not set, each engine keeps its own default. The JDK engine verifies the certificate chain and the hostname. Gatling 3.16's own default is to trust every certificate with no hostname check. For an https baseUrl on Gatling without loadtest.tls, TestFly logs one INFO per run: Gatling does not verify server certificates by default; set loadtest.tls.trustStore. Set loadtest.tls.trustStore to get verification on Gatling. Changing this default is deferred to 2.0, because it would break existing HTTPS Gatling runs.

The -Dgatling.ssl.* and -Dgatling.http.enableHostnameVerification options are reserved in loadtest.gatling.jvmArgs: TestFly sets them from loadtest.tls.

Proxy and TLS settings are read from testfly.yml only; there is no LoadScenario or @LoadTest setting for them. A simulation uses the first scenario's configuration, as for resultsDir. In code, LoadTestConfig.getProxy() and getTls() return copies of the resolved values.


4. How secrets are protected​

  • Placeholders. Write ${ENV} placeholders in testfly.yml and read secrets with System.getenv(..) in code. Never write a literal secret.
  • Gatling child JVM. Secrets reach the Gatling child only through the run's temporary configuration file, which is readable by its owner only (mode 0600 on POSIX; Windows gets no extra ACL). The child reads it and deletes it at start-up, and the test JVM deletes it again when the run ends. Secrets are never put on the command line or in environment variables. The child's main class is now io.testfly.loadtest.internal.GatlingChildMain, which sets the TLS system properties in-process and then starts Gatling.
  • Redacted subprocess log. gatling-subprocess.log is written through a redacting filter: every password, client secret, token and Basic credential that TestFly knows is replaced by ***, as are Authorization and Proxy-Authorization lines and bare Basic <base64> values. This covers a logback-test.xml that logs Gatling's HTTP traffic at DEBUG.
  • Messages and toString(). Validation and runtime messages name keys, variables, paths and URLs without user info or query, never values. LoadAuth, loadtest.proxy and the stores mask their passwords in toString(); LoadTestConfig.toString() does not show proxy or TLS settings.
  • What stays readable. The child JVM holds the passwords in memory and in its system properties for the run. A process of the same OS user can read them, for example with jcmd. This is the same trust boundary as the temporary file. If both JVMs are killed with kill -9, the temporary file can stay in the temp directory.

5. Engine differences​

FeatureJDK engineGatling engine
Basic and BearerHeader built per requestSame bytes, built by TestFly per request
Unresolved credential ${var}KO, status -1, nothing sentSame
OAuth2 client credentialsToken fetched and refreshed in the test JVMToken fetched in the test JVM, refreshed in the child JVM
Same-origin redirect hopKeeps the credentialsSent without credentials
Proxy credentialsPreemptive, also on CONNECT for HTTPS (needs jdk.http.auth.tunneling.disabledSchemes to allow Basic)Preemptive
nonProxyHosts wildcardsMatched for every hostMatched for hosts known before the run
loadtest.proxy unsetJVM proxy propertiesNo proxy set by TestFly
loadtest.tls unsetVerifies the chain and the hostnameTrusts every certificate, no hostname check (Gatling default)
loadtest.tls setVerifies the chain and the hostnameSame

Not supported in 1.1.0​

Deferred to a later version: digest, HMAC and apiKey entries from api.auth (for an API key, use header(..)); the OAuth2 password and refresh-token grants; a token per virtual user; SOCKS proxies; proxy or TLS settings per scenario; a client certificate per virtual user; Gatling verifying certificates by default; TLS protocol and cipher settings; a Windows ACL on the temporary configuration file; token requests in the metrics.