Authenticate with JWT

Mint JWTs inside your simulation so every virtual user authenticates like a real client, without putting load on your identity provider.

This guide shows two common JWT use cases:

  • Generate a JWT per virtual user and send it as a bearer token.
  • Sign each request with a JWT that covers its method, URL, and body.

The examples use Nimbus JOSE + JWT, a widely used Java library that supports all standard JWT algorithms and doesn’t pull in any other dependencies.

Meet the prerequisites

Before you begin, make sure you have the following:

  • Gatling version 3.15.1 or higher, with the Java, Kotlin, or Scala SDK
  • The signing key your system under test accepts: a shared secret for HMAC algorithms such as HS256, or a private key for RSA algorithms such as RS256
  • Basic understanding of feeders and the Session

Understand how JWT authentication works

A JWT is a compact string made of three Base64URL-encoded parts separated by dots: header.payload.signature.

  • The header declares the signing algorithm, for example HS256, and optionally a key ID.
  • The payload holds the claims, such as the subject (sub), the issuer (iss), the audience (aud), and the expiration time (exp).
  • The signature proves that the holder of the signing key issued the token and that nobody modified it.

In production, an identity provider usually issues the tokens. During a load test, fetching a token for every virtual user can overload the identity provider or trip its rate limits, and it isn’t the system you want to measure. If you can access the signing key, generate the tokens directly in your simulation instead.

Add the JWT library to your project

Add the com.nimbusds:nimbus-jose-jwt dependency to your Gatling project. Check Maven Central for the latest version.

Add the following dependency to your build.gradle:

dependencies {
  gatlingImplementation "com.nimbusds:nimbus-jose-jwt:10.10"
}

Add the following dependency to your pom.xml:

<dependency>
  <groupId>com.nimbusds</groupId>
  <artifactId>nimbus-jose-jwt</artifactId>
  <version>10.10</version>
  <scope>test</scope>
</dependency>

Add the following dependency to your build.sbt:

libraryDependencies += "com.nimbusds" % "nimbus-jose-jwt" % "10.10" % Test

The examples in this guide use the following imports:

     
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.util.Base64URL;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import io.gatling.javaapi.core.*;
import io.gatling.javaapi.http.*;

import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;

import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.JWSSigner
import com.nimbusds.jose.crypto.MACSigner
import com.nimbusds.jose.crypto.RSASSASigner
import com.nimbusds.jose.util.Base64URL
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import io.gatling.javaapi.core.CoreDsl.*
import io.gatling.javaapi.http.HttpDsl.*

import java.security.KeyFactory
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.spec.PKCS8EncodedKeySpec
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.Date
import java.util.UUID
import java.security.{ KeyFactory, MessageDigest, PrivateKey }
import java.security.spec.PKCS8EncodedKeySpec
import java.time.Instant
import java.util.{ Base64, Date, UUID }

import scala.concurrent.duration._

import io.gatling.core.Predef._
import io.gatling.http.Predef._

import com.nimbusds.jose.{ JWSAlgorithm, JWSHeader, JWSSigner }
import com.nimbusds.jose.crypto.{ MACSigner, RSASSASigner }
import com.nimbusds.jose.util.Base64URL
import com.nimbusds.jwt.{ JWTClaimsSet, SignedJWT }

Generate a JWT token

Generating a token takes three steps: create a signer from your key, build and sign the claims, and send the result in the Authorization header.

Create a signer

A signer holds the key and computes signatures. Signers are thread-safe, so create a single instance and share it across all virtual users.

The following example creates an HMAC signer from a shared secret stored in the JWT_SECRET environment variable:

     
// Read the shared secret from an environment variable instead of hard-coding it.
// HS256 requires a secret of at least 256 bits (32 bytes).
private static final JWSSigner SIGNER;

static {
  try {
    SIGNER = new MACSigner(System.getenv("JWT_SECRET"));
  } catch (KeyLengthException e) {
    throw new IllegalStateException("JWT_SECRET must be at least 32 bytes long", e);
  }
}
// Read the shared secret from an environment variable instead of hard-coding it.
// HS256 requires a secret of at least 256 bits (32 bytes).
private val signer: JWSSigner = MACSigner(
  requireNotNull(System.getenv("JWT_SECRET")) { "JWT_SECRET is not set" }
)
// Read the shared secret from an environment variable instead of hard-coding it.
// HS256 requires a secret of at least 256 bits (32 bytes).
private val signer: JWSSigner = new MACSigner(
  sys.env.getOrElse("JWT_SECRET", throw new IllegalStateException("JWT_SECRET is not set"))
)

Build and sign the token

Build the claims your system under test expects, then sign them. Adjust the issuer, the audience, and any custom claims to match what your server validates.

     
private static String generateToken(String subject) {
  Instant now = Instant.now();
  JWTClaimsSet claims = new JWTClaimsSet.Builder()
    .issuer("gatling")
    .audience("https://api.example.com")
    .subject(subject)
    .issueTime(Date.from(now))
    .expirationTime(Date.from(now.plus(Duration.ofMinutes(15))))
    // unique token ID, in case the server rejects replayed tokens
    .jwtID(UUID.randomUUID().toString())
    .build();

  SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims);
  try {
    jwt.sign(SIGNER);
  } catch (JOSEException e) {
    throw new IllegalStateException("Failed to sign JWT", e);
  }
  // compact form: header.payload.signature
  return jwt.serialize();
}
private fun generateToken(subject: String): String {
  val now = Instant.now()
  val claims = JWTClaimsSet.Builder()
    .issuer("gatling")
    .audience("https://api.example.com")
    .subject(subject)
    .issueTime(Date.from(now))
    .expirationTime(Date.from(now.plus(Duration.ofMinutes(15))))
    // unique token ID, in case the server rejects replayed tokens
    .jwtID(UUID.randomUUID().toString())
    .build()

  val jwt = SignedJWT(JWSHeader(JWSAlgorithm.HS256), claims)
  jwt.sign(signer)
  // compact form: header.payload.signature
  return jwt.serialize()
}
private def generateToken(subject: String): String = {
  val now = Instant.now()
  val claims = new JWTClaimsSet.Builder()
    .issuer("gatling")
    .audience("https://api.example.com")
    .subject(subject)
    .issueTime(Date.from(now))
    .expirationTime(Date.from(now.plusSeconds(15.minutes.toSeconds)))
    // unique token ID, in case the server rejects replayed tokens
    .jwtID(UUID.randomUUID().toString)
    .build()

  val jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims)
  jwt.sign(signer)
  // compact form: header.payload.signature
  jwt.serialize()
}

Send the token in the Authorization header

Create a users.csv file in the resources folder of your Gatling project to give each virtual user its own identity:

userId
user1
user2
user3

The following scenario generates a token for each virtual user, stores it in the Session under the jwt key, and sends it as a bearer token with the Gatling Expression Language:

     
HttpProtocolBuilder httpProtocol = http
  .baseUrl("https://api.example.com")
  .acceptHeader("application/json");

FeederBuilder<String> usersFeeder = csv("users.csv").circular();

ScenarioBuilder scn = scenario("JWT authentication")
  .feed(usersFeeder)
  // generate a token for the virtual user and store it in its Session
  .exec(session -> session.set("jwt", generateToken(session.getString("userId"))))
  .exec(
    http("Get profile")
      .get("/api/profile")
      .header("Authorization", "Bearer #{jwt}")
      .check(status().is(200)),
    http("Update profile")
      .put("/api/profile")
      .header("Authorization", "Bearer #{jwt}")
      .body(StringBody("{\"displayName\": \"#{userId}\"}"))
      .asJson()
      .check(status().is(200))
  );
val httpProtocol = http
  .baseUrl("https://api.example.com")
  .acceptHeader("application/json")

val usersFeeder = csv("users.csv").circular()

val scn = scenario("JWT authentication")
  .feed(usersFeeder)
  // generate a token for the virtual user and store it in its Session
  .exec { session -> session.set("jwt", generateToken(session.getString("userId")!!)) }
  .exec(
    http("Get profile")
      .get("/api/profile")
      .header("Authorization", "Bearer #{jwt}")
      .check(status().`is`(200)),
    http("Update profile")
      .put("/api/profile")
      .header("Authorization", "Bearer #{jwt}")
      .body(StringBody("""{"displayName": "#{userId}"}"""))
      .asJson()
      .check(status().`is`(200))
  )
val httpProtocol = http
  .baseUrl("https://api.example.com")
  .acceptHeader("application/json")

val usersFeeder = csv("users.csv").circular

val scn = scenario("JWT authentication")
  .feed(usersFeeder)
  // generate a token for the virtual user and store it in its Session
  .exec(session => session.set("jwt", generateToken(session("userId").as[String])))
  .exec(
    http("Get profile")
      .get("/api/profile")
      .header("Authorization", "Bearer #{jwt}")
      .check(status.is(200)),
    http("Update profile")
      .put("/api/profile")
      .header("Authorization", "Bearer #{jwt}")
      .body(StringBody("""{"displayName": "#{userId}"}"""))
      .asJson
      .check(status.is(200))
  )

Sign with an RSA private key

If your server verifies tokens with a public key, sign them with the matching private key and an asymmetric algorithm such as RS256. The following example loads a PKCS#8 PEM-encoded private key from the JWT_PRIVATE_KEY environment variable:

     
// Read a PKCS#8 PEM-encoded private key ("-----BEGIN PRIVATE KEY-----") from an environment variable
private static final JWSSigner SIGNER = new RSASSASigner(loadPrivateKey(System.getenv("JWT_PRIVATE_KEY")));

private static PrivateKey loadPrivateKey(String pem) {
  String base64 = pem
    .replace("-----BEGIN PRIVATE KEY-----", "")
    .replace("-----END PRIVATE KEY-----", "")
    .replaceAll("\\s", "");
  try {
    return KeyFactory.getInstance("RSA")
      .generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(base64)));
  } catch (GeneralSecurityException e) {
    throw new IllegalStateException("Invalid RSA private key", e);
  }
}

private static String generateToken(String subject) {
  Instant now = Instant.now();
  JWTClaimsSet claims = new JWTClaimsSet.Builder()
    .issuer("gatling")
    .audience("https://api.example.com")
    .subject(subject)
    .issueTime(Date.from(now))
    .expirationTime(Date.from(now.plus(Duration.ofMinutes(15))))
    .jwtID(UUID.randomUUID().toString())
    .build();

  // the key ID tells the server which public key to verify the signature with
  JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("gatling-key").build();
  SignedJWT jwt = new SignedJWT(header, claims);
  try {
    jwt.sign(SIGNER);
  } catch (JOSEException e) {
    throw new IllegalStateException("Failed to sign JWT", e);
  }
  return jwt.serialize();
}
// Read a PKCS#8 PEM-encoded private key ("-----BEGIN PRIVATE KEY-----") from an environment variable
private val signer: JWSSigner = RSASSASigner(
  loadPrivateKey(requireNotNull(System.getenv("JWT_PRIVATE_KEY")) { "JWT_PRIVATE_KEY is not set" })
)

private fun loadPrivateKey(pem: String): PrivateKey {
  val base64 = pem
    .replace("-----BEGIN PRIVATE KEY-----", "")
    .replace("-----END PRIVATE KEY-----", "")
    .replace(Regex("\\s"), "")
  return KeyFactory.getInstance("RSA")
    .generatePrivate(PKCS8EncodedKeySpec(Base64.getDecoder().decode(base64)))
}

private fun generateToken(subject: String): String {
  val now = Instant.now()
  val claims = JWTClaimsSet.Builder()
    .issuer("gatling")
    .audience("https://api.example.com")
    .subject(subject)
    .issueTime(Date.from(now))
    .expirationTime(Date.from(now.plus(Duration.ofMinutes(15))))
    .jwtID(UUID.randomUUID().toString())
    .build()

  // the key ID tells the server which public key to verify the signature with
  val header = JWSHeader.Builder(JWSAlgorithm.RS256).keyID("gatling-key").build()
  val jwt = SignedJWT(header, claims)
  jwt.sign(signer)
  return jwt.serialize()
}
// Read a PKCS#8 PEM-encoded private key ("-----BEGIN PRIVATE KEY-----") from an environment variable
private val signer: JWSSigner = new RSASSASigner(
  loadPrivateKey(sys.env.getOrElse("JWT_PRIVATE_KEY", throw new IllegalStateException("JWT_PRIVATE_KEY is not set")))
)

private def loadPrivateKey(pem: String): PrivateKey = {
  val base64 = pem
    .replace("-----BEGIN PRIVATE KEY-----", "")
    .replace("-----END PRIVATE KEY-----", "")
    .replaceAll("\\s", "")
  KeyFactory
    .getInstance("RSA")
    .generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder.decode(base64)))
}

private def generateToken(subject: String): String = {
  val now = Instant.now()
  val claims = new JWTClaimsSet.Builder()
    .issuer("gatling")
    .audience("https://api.example.com")
    .subject(subject)
    .issueTime(Date.from(now))
    .expirationTime(Date.from(now.plusSeconds(15.minutes.toSeconds)))
    .jwtID(UUID.randomUUID().toString)
    .build()

  // the key ID tells the server which public key to verify the signature with
  val header = new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("gatling-key").build()
  val jwt = new SignedJWT(header, claims)
  jwt.sign(signer)
  jwt.serialize()
}

If your key is in the PKCS#1 format (-----BEGIN RSA PRIVATE KEY-----), convert it to PKCS#8 first:

openssl pkcs8 -topk8 -nocrypt -in private-key.pem -out private-key-pkcs8.pem

Sign each request

Some APIs require a signature for each request instead of, or in addition to, a bearer token. The signature covers the request’s content, so the server can reject any request that was modified or replayed.

Use the sign method to compute the signature. Gatling calls your function once it has built the request, right before sending it, so the function has access to the final method, URL, headers, and body.

The following example reuses the HMAC signer from the previous section. For each request, it builds a JWT that contains the HTTP method (htm), the target URL (htu), and a SHA-256 hash of the body (bsh), then sends it in the X-Request-Signature header:

     
HttpProtocolBuilder httpProtocol = http
  .baseUrl("https://api.example.com")
  // runs once Gatling has built the request, right before sending it
  .sign(request -> {
    byte[] body = request.getBody() != null ? request.getBody().getBytes() : new byte[0];
    JWTClaimsSet claims = new JWTClaimsSet.Builder()
      .issuer("gatling")
      .issueTime(new Date())
      .jwtID(UUID.randomUUID().toString())
      // bind the signature to this exact request
      .claim("htm", request.getMethod().name())
      .claim("htu", request.getUri().toUrl())
      .claim("bsh", sha256(body))
      .build();
    request.getHeaders().set("X-Request-Signature", sign(claims));
    return request;
  });

private static String sha256(byte[] bytes) {
  try {
    return Base64URL.encode(MessageDigest.getInstance("SHA-256").digest(bytes)).toString();
  } catch (NoSuchAlgorithmException e) {
    throw new IllegalStateException(e);
  }
}

private static String sign(JWTClaimsSet claims) {
  SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims);
  try {
    jwt.sign(SIGNER);
  } catch (JOSEException e) {
    throw new IllegalStateException("Failed to sign request", e);
  }
  return jwt.serialize();
}
val httpProtocol = http
  .baseUrl("https://api.example.com")
  // runs once Gatling has built the request, right before sending it
  .sign { request ->
    val body = request.body?.bytes ?: ByteArray(0)
    val claims = JWTClaimsSet.Builder()
      .issuer("gatling")
      .issueTime(Date())
      .jwtID(UUID.randomUUID().toString())
      // bind the signature to this exact request
      .claim("htm", request.method.name())
      .claim("htu", request.uri.toUrl())
      .claim("bsh", sha256(body))
      .build()
    request.headers.set("X-Request-Signature", sign(claims))
    request
  }

private fun sha256(bytes: ByteArray): String =
  Base64URL.encode(MessageDigest.getInstance("SHA-256").digest(bytes)).toString()

private fun sign(claims: JWTClaimsSet): String {
  val jwt = SignedJWT(JWSHeader(JWSAlgorithm.HS256), claims)
  jwt.sign(signer)
  return jwt.serialize()
}
val httpProtocol = http
  .baseUrl("https://api.example.com")
  // runs once Gatling has built the request, right before sending it
  .sign { (request, _) =>
    val body = Option(request.getBody).map(_.getBytes).getOrElse(Array.emptyByteArray)
    val claims = new JWTClaimsSet.Builder()
      .issuer("gatling")
      .issueTime(new Date())
      .jwtID(UUID.randomUUID().toString)
      // bind the signature to this exact request
      .claim("htm", request.getMethod.name)
      .claim("htu", request.getUri.toUrl)
      .claim("bsh", sha256(body))
      .build()
    request.getHeaders.set("X-Request-Signature", sign(claims))
    request
  }

private def sha256(bytes: Array[Byte]): String =
  Base64URL.encode(MessageDigest.getInstance("SHA-256").digest(bytes)).toString

private def sign(claims: JWTClaimsSet): String = {
  val jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims)
  jwt.sign(signer)
  jwt.serialize()
}

The claim names and the header name depend on your API’s contract. Adjust them to match what your server verifies.

Next steps

Edit this page on GitHub