🛡️ Implementing a JWT-based authorization for zio-http

Recently I’ve published my pac4j wrapper for zio-http, zio-http-pac4j. For those who aren’t familiar with the underlying technology, pac4j is a Java security framework which allows you to easily implement authorization and authentication mechanisms. Today I want to show how to implement JWT-based authorization using this library.

Introduction

Firstly, let’s quickly remember what JWT is. As RFC-7519 says:

JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted.

The very basic JWT token looks like:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30

Here you can see three parts split by dots. Each part is a base64-urlencoded string and respectively they are a header, payload and signature. To go deeper and find what exactly they are and what they can contain, it’s better to refer to the RFC itself or look at some good JWT introduction article. Now let’s jump right to the guide.

Implementing Basic Auth

Now, imagine you have your zio-http API, which you want to protect using JWT tokens. There are numerous ways to configure everything, but we’ll discuss the most common case: using basic auth to log in, and then using JWT token to access our protected API.

Start with adding necessary dependencies:

val pac4jVersion = "6.2.1"

libraryDependencies ++= Seq(
  // pac4j + zio-http integration
  "me.seroperson" %% "zio-http-pac4j" % "0.1.1",
  // JWT-related pac4j classes
  "org.pac4j" % "pac4j-http" % pac4jVersion
)

zio-http-pac4j requires you to define security configuration via the special class SecurityConfig. Let’s fill it with Basic Auth settings:

import me.seroperson.zio.http.pac4j.config.SecurityConfig
import org.pac4j.http.client.direct.DirectBasicAuthClient
import org.pac4j.http.credentials.authenticator.test.SimpleTestUsernamePasswordAuthenticator

val securityConfig = SecurityConfig(
  clients = List({
    val directBasicAuthClient = new DirectBasicAuthClient(
      new SimpleTestUsernamePasswordAuthenticator()
    )
    directBasicAuthClient
  })
)

We used SimpleTestUsernamePasswordAuthenticator here, which allows all users that have username == password, so it should be only used for testing purposes. Let’s now define our login endpoint, protected using Basic Auth.

// ...
import zio.ZIOAppDefault
import zio.http._
import me.seroperson.zio.http.pac4j.Pac4jMiddleware
import me.seroperson.zio.http.pac4j.ZioPac4jDefaults
import me.seroperson.zio.http.pac4j.config.SecurityConfig
import me.seroperson.zio.http.pac4j.session.InMemorySessionRepository

object ZioApi extends ZIOAppDefault {

  val userRoutes = Routes(
    Method.GET / "jwt" -> handler {
      Response.ok
    } @@ Pac4jMiddleware.securityFilterUnit(clients = Some(List("DirectBasicAuthClient")))
  )

  override val run = for {
    _ <- Server
      .serve(userRoutes)
      .provide(
        Server.defaultWithPort(9000),
        ZioPac4jDefaults.live,
        InMemorySessionRepository.live,
        ZLayer.succeed {
          SecurityConfig(/* ... */)
        }
      )
  } yield ()
}

Here we use ZioPac4jDefaults.live to provide the necessary pac4j classes and InMemorySessionRepository.live to provide storage for your sessions (be sure to implement some more reliable storage for production purposes).

Let’s start our server using sbt run and make a curl request to ensure everything works.

$ > curl -v -u admin:admin "http://localhost:9000/jwt"
* Host localhost:9000 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:9000...
* Connected to localhost (::1) port 9000
* Server auth using Basic with user 'admin'
> GET /jwt HTTP/1.1
> Host: localhost:9000
> Authorization: Basic YWRtaW46YWRtaW4=
> User-Agent: curl/8.8.0
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 Ok
< date: Tue, 02 Sep 2025 16:30:42 GMT
< content-length: 0
<
* Connection #0 to host localhost left intact

Implementing JWT-based authorization

Now let’s add more configuration to implement JWT-based authorization. We’ll leverage ZLayer power to reuse it later in endpoint’s body:

// ...
import org.pac4j.http.client.direct.HeaderClient
import org.pac4j.jwt.config.signature.SecretSignatureConfiguration
import org.pac4j.jwt.config.signature.SignatureConfiguration
import org.pac4j.jwt.credentials.authenticator.JwtAuthenticator

object ZioApi extends ZIOAppDefault {

  val userRoutes = Routes(
    /* ... */
  )

  override val run = for {
    _ <- Server
      .serve(userRoutes)
      .provide(
        // ...
        ZLayer.succeed[SignatureConfiguration] {
          new SecretSignatureConfiguration(
            // 256-bits
            "VOAAvi(F2Wi9LiybnxNOJGSryxX58@;v@5Ciz5Cv~WQ|8_yh]ZAIhqDAYhZ3}r{"
          )
        },
        ZLayer.fromZIO {
          for {
            signatureConfig <- ZIO.service[SignatureConfiguration]
          } yield SecurityConfig(clients = List(
            {
              val jwtAuthenticator = new JwtAuthenticator()
              jwtAuthenticator.setSignatureConfiguration(signatureConfig)

              val headerClient = new HeaderClient(
                Header.Authorization.name,
                jwtAuthenticator
              )
              headerClient
            }, /* ... */
          ))
        }
      )
  } yield ()
}

Here we’ve defined everything we need to protect our endpoint using JWT token which we’ll pass using Authorization header. Our token will be signed using given secret, so nobody will be able to fake it unless he knows that secret.

Now let’s implement JWT-token generation in our /jwt endpoint:

import org.pac4j.jwt.profile.JwtGenerator
import java.util.Date

val userRoutes = Routes(
  Method.GET / "jwt" -> handler {
    for {
      profile <- ZIO.service[UserProfile]
      signatureConfig <- ZIO.service[SignatureConfiguration]
      jwtGenerator = {
        val jwtGenerator = new JwtGenerator(signatureConfig)
        jwtGenerator
      }
      token = jwtGenerator.generate(profile)
    } yield Response.ok.copy(body = Body.fromCharSequence(token))
  } @@[SignatureConfiguration] Pac4jMiddleware
    .securityFilter(clients = Some(List("DirectBasicAuthClient")))
)

And, finally, let’s implement some JWT-protected endpoint:

val userRoutes = Routes(
  /* ... */
  Method.GET / "protected" -> handler {
    for {
      profile <- ZIO.service[UserProfile]
    } yield Response.ok
      .copy(body = Body.fromCharSequence(profile.getUsername))
  } @@ Pac4jMiddleware
    .securityFilter(clients = Some(List("HeaderClient")))
)

Checking again that everything works:

$ > curl -v -u admin:admin "http://localhost:9000/jwt"
* Host localhost:9000 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:9000...
* Connected to localhost (::1) port 9000
* Server auth using Basic with user 'admin'
> GET /jwt HTTP/1.1
> Host: localhost:9000
> Authorization: Basic YWRtaW46YWRtaW4=
> User-Agent: curl/8.8.0
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 Ok
< date: Tue, 02 Sep 2025 19:27:21 GMT
< content-length: 204
<
* Connection #0 to host localhost left intact
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJvcmcucGFjNGouY29yZS5wcm9maWxlLkNvbW1vblByb2ZpbGUjYWRtaW4iLCIkaW50X3JvbGVzIjpbXSwiaWF0IjoxNzU2ODQxMjQxLCJ1c2VybmFtZSI6ImFkbWluIn0.irTIu88ah28i6gARa0iJwhk7SxpfMz481no2AT9di4A

Let’s decode it using some JWT decoder just for curiosity. The header and payload will look like:

// Header
{
  "alg": "HS256"
}

// Payload
{
  "sub": "org.pac4j.core.profile.CommonProfile#admin",
  "$int_roles": [],
  "iat": 1756841241,
  "username": "admin"
}

We can notice the following fields:

  • sub is actually optional, but filled with some internal pac4j value.
  • iat shows when this token was issued.
  • $int_roles can contain user’s roles. We’ll come back to it later.
  • username contains the username (surprising).

Let’s try to access our protected endpoint:

$ > curl -v -H "Authorization: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJvcmcucGFjNGouY29yZS5wcm9maWxlLkNvbW1vblByb2ZpbGUjYWRtaW4iLCIkaW50X3JvbGVzIjpbXSwiaWF0IjoxNzU2ODQxMjQxLCJ1c2VybmFtZSI6ImFkbWluIn0.irTIu88ah28i6gARa0iJwhk7SxpfMz481no2AT9di4A" "http://localhost:9000/protected"
* Host localhost:9000 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:9000...
* Connected to localhost (::1) port 9000
> GET /protected HTTP/1.1
> Host: localhost:9000
> User-Agent: curl/8.8.0
> Accept: */*
> Authorization: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJvcmcucGFjNGouY29yZS5wcm9maWxlLkNvbW1vblByb2ZpbGUjYWRtaW4iLCIkaW50X3JvbGVzIjpbXSwiaWF0IjoxNzU2ODQxMjQxLCJ1c2VybmFtZSI6ImFkbWluIn0.irTIu88ah28i6gARa0iJwhk7SxpfMz481no2AT9di4A
>
* Request completely sent off
< HTTP/1.1 200 Ok
< date: Tue, 02 Sep 2025 19:30:22 GMT
< content-length: 5
<
* Connection #0 to host localhost left intact
admin

You can ensure by yourself that it won’t work with invalid token.

Expiration time

Issuing tokens that never expire isn’t best practice and in production you should probably ask users to rotate them sometimes. It’s a good idea to set a lifetime for your token:

jwtGenerator.setExpirationTime(
  // 1 minute
  new java.util.Date(java.lang.System.currentTimeMillis() + 1000L * 60)
)

Decode this token again and you’ll see something like that:

{
  "sub": "org.pac4j.core.profile.CommonProfile#admin",
  "$int_roles": [],
  "exp": 1756842478,
  "iat": 1756842418,
  "username": "admin"
}

Token encryption

To not allow your users to check what your token contains, you can configure token encryption. It’s possible to encrypt them using a secret or using key pair. Let’s check both ways, starting with key pair.

Using key pair

Just for example we’ll generate pair in runtime, but in production you should probably read it from some safe place:

// ...
import org.pac4j.jwt.config.encryption.EncryptionConfiguration
import org.pac4j.jwt.config.encryption.ECEncryptionConfiguration
import com.nimbusds.jose.JWEAlgorithm
import com.nimbusds.jose.EncryptionMethod
import java.security.KeyPairGenerator

object ZioApi extends ZIOAppDefault {

  // ...

  override val run = for {
    _ <- Server
      .serve(userRoutes)
      .provide(
        /* ... */
        ZLayer.succeed[EncryptionConfiguration] {
          val keyGen = KeyPairGenerator.getInstance("EC")
          val ecKeyPair = keyGen.generateKeyPair()
          val encConfig = new ECEncryptionConfiguration(ecKeyPair)
          encConfig.setAlgorithm(JWEAlgorithm.ECDH_ES_A128KW)
          encConfig.setMethod(EncryptionMethod.A192CBC_HS384)
          encConfig
        },
        ZLayer.fromZIO {
          for {
            encConfig <- ZIO.service[EncryptionConfiguration]
            signatureConfig <- ZIO.service[SignatureConfiguration]
          } yield SecurityConfig(clients = List(
            {
              val jwtAuthenticator = new JwtAuthenticator()

              jwtAuthenticator.addEncryptionConfiguration(encConfig)
              jwtAuthenticator.setSignatureConfiguration(signatureConfig)

              /* ... */
            }, /* ... */
          ))
        }
      )
  } yield ()
}

And edit our token-issuing endpoint to generate encrypted tokens:

// ...
val userRoutes = Routes(
  Method.GET / "jwt" -> handler {
    for {
      profile <- ZIO.service[UserProfile]
      encConfig <- ZIO.service[EncryptionConfiguration]
      signatureConfig <- ZIO.service[SignatureConfiguration]
      jwtGenerator = {
        val jwtGenerator = new JwtGenerator(signatureConfig, encConfig)
        /* ... */
        jwtGenerator
      }
      token = jwtGenerator.generate(profile)
    } yield Response.ok.copy(body = Body.fromCharSequence(token))
  } @@[EncryptionConfiguration with SignatureConfiguration] Pac4jMiddleware
    .securityFilter(clients = Some(List("DirectBasicAuthClient")))
)

Now try to issue a token and then paste it into decoder. You won’t be able to decode it anymore, neither anyone else. The cost is a larger token size, but it depends on encryption methods and algorithms. Just to compare, here is how it’ll look like with the configuration we used:

eyJlcGsiOnsia3R5IjoiRUMiLCJjcnYiOiJQLTM4NCIsIngiOiI4TVh6a21TQ1RMU2J3VTdRQ2c2NmM1VWRIZ1VPR21fS2g4Q3lRVVZIOEgzR1dCeUNvaHJ2WUVLRzhOV1hOUUlSIiwieSI6IkFCQkNTQi14UXRxSlVjUU5rVUI3QzNqeHl4TzFQbW5GdEhjb2dIanFFMDZIVHBTTkQ4YWVkZWlJR3dPS01pVVMifSwiY3R5IjoiSldUIiwiZW5jIjoiQTE5MkNCQy1IUzM4NCIsImFsZyI6IkVDREgtRVMrQTEyOEtXIn0.TJAr2-oPN4DJt7MpSHx4jtXmGiaYvFCKZ3lbcdu4Ssad7h8z2utGd26GkZ8KWKGCQ_CwoKeDzgQ.C7SPaeOYitfqARDYvkTwag.tAg_6FNnVgPnrzScrVFWjSnNjiybBwvn5BN5XWLvF9S_L7UrPSQrshb5mZ3s7-0TaCjf15_lDV5tTZwRasa9BHWGlFDqnLrVRUlF1G0xZ29n4G7xTPEBWyYfUDcNPtn9qmWLQKVpylnUlFEKDGzjnbZoJ8m5oZnEfwyz0U083IgWdgQ_ZT9ecSW3p6dbcdPm1uOUiMdG2c9-K-1NvIoUHW_4EyRy0iXdC6vmKl1bPv2k2ZPywR5d-7IoC5SkKmUFXGZi1CC5rdScifvQRM5aYKI1ours0Kxmuc91waFtHHQ1jjx3TUhCydneQKPLuC9v.zWE9JERIo8jhYudjtqc2dlixuvicxeBY

Using a secret

Encryption using a secret is much easier to manage and will look like this:

import org.pac4j.jwt.config.encryption.SecretEncryptionConfiguration
import com.nimbusds.jose.EncryptionMethod
import com.nimbusds.jose.JWEAlgorithm

ZLayer.succeed[EncryptionConfiguration] {
  new SecretEncryptionConfiguration(
    // 128-bit secret key
    "d734b141ab4b62e6f4c4fb0cda5ca435",
    /* algorithm = */ JWEAlgorithm.DIR,
    /* encMethod = */ EncryptionMethod.A256GCM
  )
}

You can pass different algorithms and encryption methods, but if you don’t know what they mean, it’s better to stick to the defaults.

Such encryption will produce a token like:

eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiZGlyIn0..10VThBBga38rzyzG.ZYXz5xLMoLcySFRinndM-NAoEh93UVd8fQTS-AdPXW7nQsZw55bTkwO58Qx62f2_POSLi70_D3eTs0zrfrrKn436zDB-h6J8JprbnIC2zDjXFoBV6IMgkHMxk243no5fsG28tPdYdobiXWnsk7aOeNeCyBFq1f13HxiCp3E0xzTEa9RXj7vf_jQwTIblVExDDL28VcnopkdY8F4CD9KQHhStXbQxRN80GMdScRkItS-sZIu7QhdlRee-9V_bD201nNWBaazCz5vVd9quj7BWHMoSe48reTtEjYjP9Zeqs3h4Yrc._shfe6akHWBMg4jUud4dkg

Restricting access by role

Imagine we need to restrict users from calling some specific routes which are intended to be used only by admins. We can do it using pac4j’s Authorizer classes. Let’s see how it’ll look like, starting from assigning a role to API user:

// ...
import org.pac4j.core.context.CallContext
import org.pac4j.core.credentials.Credentials
import org.pac4j.core.profile.creator.AuthenticatorProfileCreator
import org.pac4j.core.profile.creator.ProfileCreator
import scala.jdk.OptionConverters._

val directBasicAuthClient = new DirectBasicAuthClient(
  new SimpleTestUsernamePasswordAuthenticator()
)

directBasicAuthClient.setProfileCreator(new ProfileCreator() {

  override def create(
    ctx: CallContext,
    credentials: Credentials
  ): Optional[UserProfile] =
    AuthenticatorProfileCreator.INSTANCE
      .create(ctx, credentials)
      .toScala
      .map { profile =>
        if (profile.getUsername == "admin") {
          profile.addRole("admin")
        } else {
          profile.addRole("user")
        }
        profile
      }
      .toJava

})

Here we edit our directBasicAuthClient, which protects our /jwt endpoint. As you can see, we implemented a new ProfileCreator class, which assigns a role depending on the username. In a real application, you would retrieve it from a database or another data source.

Worth to say that here we also can add custom payload to retrieve it back from JWT token later in endpoint’s body:

if (profile.getUsername == "admin") {
  profile.addRole("admin")
} else {
  profile.addAttribute("key", "value")
  profile.addRole("user")
}

We also need to pass an Authorizer to the SecurityConfig:

import org.pac4j.core.authorization.authorizer.RequireAllRolesAuthorizer

SecurityConfig(
  clients = /* ... */,
  authorizers = List("only-admin" -> new RequireAllRolesAuthorizer("admin"))
)

The only thing left is to put only-admin authorizer onto our /protected endpoint:

Method.GET / "protected" -> handler {
  /* .. */
} @@ Pac4jMiddleware.securityFilter(
  clients = Some(List("HeaderClient")),
  authorizers = List("only-admin")
)

Now you can check your endpoint to ensure it’s restricted from non-admin access. Decoding the JWT token for the seroperson user now shows something like this:

{
  "sub": "org.pac4j.core.profile.CommonProfile#seroperson",
  "$int_roles": ["user"],
  "exp": 1756853202,
  "iat": 1756853142,
  "username": "seroperson"
}

Conclusion

Here we saw how powerful pac4j is and how to use it with zio-http. Some code might look a little “Javish”, so you’ll have to deal with it using some wrappers. Maybe in the future I’ll include such wrappers (like ZioProfileCreator) in zio-http-pac4j to get rid of Java’s leftovers. It’s worth noting that we saw only a very small part of pac4j’s power. The rest is available in the documentation and by exploring their API.

For the complete sources, check the repository.

Thank you for reading this little article, I hope it will be useful to someone. Feel free to reach me if you have something to say.

And also take a look on the posts on similar topics: