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.
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.
| Factory | Sends | Where |
|---|---|---|
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 authentication | step (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 has | The request sends |
|---|---|
auth(..) or basicAuth(..) | the step's authentication |
noAuth() | no authentication |
header("Authorization", ..) and no step auth | that header; the scenario's auth is not added |
auth(..) and header("Authorization", ..) | rejected before the run (IllegalArgumentException) |
| none of these | the 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 OAuth2errorcode, 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 missingexpires_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.
POSTto the token URL, form-encodedgrant_type=client_credentialsplusscopewhen set. The client authenticates withclient_idandclient_secretin the form (client_secret_post), or with anAuthorization: Basicheader after.clientSecretBasic(). Only HTTP 200 with a non-blankaccess_tokenis accepted; atoken_typeother thanbearer(any case) is rejected; arefresh_tokenis ignored. The request usesloadtest.proxy,loadtest.tlsandrequestTimeoutSeconds. - 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 andextract(..), 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 throwsIllegalArgumentException.
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
nulluser 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
httporhttpsURL, 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 inapi.auth, or an unsupported type.- Both on one step: a step with its own auth and an
Authorizationheader (name in any case). - Credentials in the URL: a scenario
baseUrlwith user info (https://user:pass@host) when any auth is configured. UsebasicAuth(..).
2. Proxy (loadtest.proxy)
loadtest:
proxy:
host: proxy.corp.example
port: 3128
username: ${LOAD_PROXY_USER}
password: ${LOAD_PROXY_PASSWORD}
nonProxyHosts: [localhost, "*.internal.example"]
| Key | Type | Default | Description |
|---|---|---|---|
proxy.host | string | unset | Proxy host name or IP, without scheme, path or @. Unset means no configured proxy. |
proxy.port | int | 0 | 1 to 65535; required with a host. |
proxy.username | string | unset | Proxy user. Set both username and password, or neither. |
proxy.password | string | unset | Proxy password. Use a ${ENV} placeholder. |
proxy.nonProxyHosts | list | empty | Hosts 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-Authorizationon plain-HTTP requests and on theCONNECTrequest that opens an HTTPS tunnel. - No fallback to
api.proxy.loadtest.proxyandapi.proxyare 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
nonProxyHostsentries plus every known host that matches a wildcard: the scenariobaseUrl, the token URL and literal absolute step URLs. A host that is only known at run time, such ashttps://${host}/x, matches a wildcard on the JDK engine only.
- The JDK refuses Basic authentication for a
CONNECTtunnel unless the JVM-wide propertyjdk.http.auth.tunneling.disabledSchemesallows it (by default it disablesBasic, and the JDK then strips theProxy-Authorizationheader from theCONNECT). 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}
| Key | Type | Default | Description |
|---|---|---|---|
tls.trustStore.path | string | unset | Trust store file. It replaces the default trust. |
tls.trustStore.type | string | PKCS12 | PKCS12 or JKS. |
tls.trustStore.password | string | unset | Store password. Use a ${ENV} placeholder. |
tls.keyStore.path | string | unset | Client key store for mTLS. |
tls.keyStore.type | string | PKCS12 | PKCS12 or JKS. |
tls.keyStore.password | string | unset | Store password; the private key must open with the same password. |
tls.insecureTrustAll | boolean | false | Trust any server certificate. The hostname is still verified. |
- Verification. With a
trustStoreor akeyStoreset, 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
keyStorewithout atrustStoreuses 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
IllegalArgumentExceptionthat 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: truetrusts 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 withtrustStore. Use it only against test environments.- No fallback to
api.ssl. As with the proxy,loadtest.tlsis separate.
loadtest.tls does not verify certificatesWhen 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 intestfly.ymland read secrets withSystem.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
0600on 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 nowio.testfly.loadtest.internal.GatlingChildMain, which sets the TLS system properties in-process and then starts Gatling. - Redacted subprocess log.
gatling-subprocess.logis written through a redacting filter: every password, client secret, token and Basic credential that TestFly knows is replaced by***, as areAuthorizationandProxy-Authorizationlines and bareBasic <base64>values. This covers alogback-test.xmlthat 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.proxyand the stores mask their passwords intoString();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 withkill -9, the temporary file can stay in the temp directory.
5. Engine differences
| Feature | JDK engine | Gatling engine |
|---|---|---|
| Basic and Bearer | Header built per request | Same bytes, built by TestFly per request |
Unresolved credential ${var} | KO, status -1, nothing sent | Same |
| OAuth2 client credentials | Token fetched and refreshed in the test JVM | Token fetched in the test JVM, refreshed in the child JVM |
| Same-origin redirect hop | Keeps the credentials | Sent without credentials |
| Proxy credentials | Preemptive, also on CONNECT for HTTPS (needs jdk.http.auth.tunneling.disabledSchemes to allow Basic) | Preemptive |
nonProxyHosts wildcards | Matched for every host | Matched for hosts known before the run |
loadtest.proxy unset | JVM proxy properties | No proxy set by TestFly |
loadtest.tls unset | Verifies the chain and the hostname | Trusts every certificate, no hostname check (Gatling default) |
loadtest.tls set | Verifies the chain and the hostname | Same |
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.