🛡️ 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 internalpac4j
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: