Skip to main content

How to Protect an Endpoint with JWT Bearer Tokens

In this guide, we protect the endpoints of a small Scala bookstore API so that only signed-in users can call them. By the end, you will have a wrapper of about twenty lines that reads the Authorization: Bearer <token> header, verifies the JSON Web Token (JWT) with Jwt.decode, answers 401 Unauthorized or 403 Forbidden the way HTTP clients expect, and hands the verified caller to your handler.

We start from an API with no protection at all and add one piece at a time:

  • Read the token from the request with the typed header API.
  • Verify it, including issuer, audience and expiry.
  • Choose between 401 and 403, with the right WWW-Authenticate challenge.
  • Wrap handlers so they only ever run for a verified caller.
  • Load the signing key from configuration as a Secret.

Every section that serves requests starts a real server on a free loopback port, calls it, and prints what came back.

The Problem​

Our bookstore has a public book list, a page with the caller's own orders, and an admin-only route that changes a book's price. Without authentication, the server can only trust what the request says about the caller. Anyone who can reach the server can read anyone's orders and change any price.

A first attempt often checks that an Authorization header is present and stops there. Here is that handler, written against the JDK HTTP server API:

import com.sun.net.httpserver.{HttpExchange, HttpHandler}
import java.nio.charset.StandardCharsets.UTF_8

val ordersHandler: HttpHandler = (exchange: HttpExchange) => {
val authorization = Option(exchange.getRequestHeaders.getFirst("Authorization"))
val (status, body) =
if (authorization.exists(_.startsWith("Bearer "))) // Any string after "Bearer " is accepted
(200, "Orders of reader-1024: Zionomicon, Functional Programming in Scala")
else
(403, "Forbidden") // Wrong status, and no WWW-Authenticate challenge for the client
val bytes = body.getBytes(UTF_8)
exchange.sendResponseHeaders(status, bytes.length.toLong)
exchange.getResponseBody.write(bytes)
exchange.close()
}

This handler lets in Bearer hello, never checks a signature or an expiry, has no idea who the caller is, and answers a missing token with 403, which tells a well-behaved client that signing in again will not help.

Declaring the requirement does not fix it either. ZIO Blocks' endpoint module has AuthType.Bearer, and it is easy to read Endpoint#auth(AuthType.Bearer) as "this endpoint is protected". It is not: AuthType only describes the requirement and says how to extract the token. Nothing in ZIO Blocks verifies a JWT on your behalf. Verification is your code, and this guide shows that code.

Prerequisites​

Add the HTTP model, the JWT module and the config module to your build.sbt. The endpoint module is only needed for the AuthType section:

// Scala 2.13 and Scala 3.3 to 3.8
libraryDependencies ++= Seq(
"dev.zio" %% "zio-blocks-http-model" % "0.0.56",
"dev.zio" %% "zio-blocks-jwt" % "0.0.56",
"dev.zio" %% "zio-blocks-config" % "0.0.56",
"dev.zio" %% "zio-blocks-endpoint" % "0.0.56" // optional
)

// Scala 3.9 and newer
libraryDependencies ++= Seq(
"dev.zio" %% "zio-blocks-next-http-model" % "0.0.56",
"dev.zio" %% "zio-blocks-next-jwt" % "0.0.56",
"dev.zio" %% "zio-blocks-next-config" % "0.0.56",
"dev.zio" %% "zio-blocks-next-endpoint" % "0.0.56" // optional
)
scalacOptions += "-experimental"

The code is the same for every Scala version. The HTTP model depends on the ZIO Blocks streams module, which is compiled for Java 21, so run the program on JDK 21 or newer.

You should know how to sign and verify a token with Jwt.sign and Jwt.decode, from How to Issue and Verify JWTs. The configuration section uses Config.load, introduced in Build a Typed Configuration Loader.

ZIO Blocks has no HTTP server. We serve requests with the small adapter from the guide How to Serve http-model Requests with the JDK HTTP Server: JdkHttp.withServer(app)(use) starts the JDK's built-in server on a free loopback port, runs use with the port, and stops the server. Client.call(port, method, target, body, authorization) sends one request and prints the status, the WWW-Authenticate header when there is one, and the body. Both files are shown in full under Running the Examples; copy them into your project.

We use these imports throughout the guide:

import java.nio.charset.StandardCharsets.UTF_8
import zio.blocks.chunk.Chunk
import zio.blocks.config.{Config, ConfigSource, Secret}
import zio.blocks.jwt._
import zio.blocks.schema.Schema
import zio.http._

The Bookstore API​

The bookstore sells three Scala books and keeps the orders of two fictional users. The responses are plain text so that the output stays short; nothing in this model knows about authentication:

final case class Book(id: Int, title: String, priceCents: Long)

object Bookstore {
val books: List[Book] = List(
Book(1, "Zionomicon", 4500),
Book(2, "Programming in Scala", 5999),
Book(3, "Functional Programming in Scala", 4999)
)

// Orders per user, keyed by the `sub` claim of their token.
val orders: Map[String, List[String]] = Map(
"reader-1024" -> List("Zionomicon", "Functional Programming in Scala"),
"staff-0007" -> List("Programming in Scala")
)

def price(cents: Long): String = f"$$${cents / 100}%d.${cents % 100}%02d"

def listBooks: Response =
Response.text(books.map(book => s"${book.id}. ${book.title} ${price(book.priceCents)}").mkString("\n"))

def ordersOf(user: String): Response =
Response.text(s"Orders of $user: ${orders.getOrElse(user, Nil).mkString(", ")}")

// Answers with the new price; a real store would also save it.
def setPrice(id: String, body: String): Response =
(books.find(_.id.toString == id), body.trim.toLongOption.filter(_ > 0)) match {
case (None, _) => Response.text(Status.NotFound, s"No book with id $id")
case (_, None) => Response.text(Status.BadRequest, "The body must be a price in cents")
case (Some(book), Some(cents)) => Response.text(s"${book.title} now costs ${price(cents)}")
}

def notFound(request: Request): Response =
Response.text(Status.NotFound, s"No route for ${request.method.name} ${request.path.encode}")
}

import Bookstore._

Without authentication, the GET /orders route has to take the user's name from the request itself, here from a ?user= query parameter. The routing matches on the method and the path segments:

def unprotectedApp(request: Request): Response =
(request.method, request.path.segments.toList) match {
case (Method.GET, List("books")) => listBooks
case (Method.GET, List("orders")) => ordersOf(request.queryParams.getFirst("user").getOrElse(""))
case (Method.PUT, List("books", id, "price")) => setPrice(id, request.body.text)
case _ => notFound(request)
}

When we serve it and call every route without any credentials, every call succeeds, including reading another user's orders and dropping the price of Zionomicon to one cent:

JdkHttp.withServer(unprotectedApp) { port =>
Client.call(port, "GET", "/books")
Client.call(port, "GET", "/orders?user=reader-1024")
Client.call(port, "PUT", "/books/1/price", "1")
}
// GET /books -> 200 [text/plain; charset=UTF-8, 93 bytes]
// 1. Zionomicon $45.00
// 2. Programming in Scala $59.99
// 3. Functional Programming in Scala $49.99
// GET /orders?user=reader-1024 -> 200 [text/plain; charset=UTF-8, 66 bytes]
// Orders of reader-1024: Zionomicon, Functional Programming in Scala
// PUT /books/1/price -> 200 [text/plain; charset=UTF-8, 26 bytes]
// Zionomicon now costs $0.01

The rest of the guide keeps GET /books public, makes GET /orders require a signed-in user, and makes PUT /books/{id}/price require a signed-in admin.

Declaring the Requirement With AuthType.Bearer​

If you describe your API with the endpoint module, you declare the requirement with Endpoint#auth. AuthType.Bearer says that the client must send Authorization: Bearer <token>, and AuthType.Scoped adds the names of the scopes a caller needs:

import zio.blocks.endpoint.{AuthType, Endpoint, PathCodec}
import zio.blocks.endpoint.RoutePattern._

val ordersEndpoint = Endpoint(Method.GET / "orders").auth(AuthType.Bearer)

val priceEndpoint =
Endpoint(Method.PUT / "books" / PathCodec.int("id") / "price")
.auth(AuthType.Scoped(AuthType.Bearer, List("admin")))

These are descriptions for documentation and for any interpreter that reads them. AuthType.Bearer carries a codec that parses the header into a Header.Authorization.Bearer, and a status for unauthorized calls:

println(ordersEndpoint.auth.unauthorizedStatus)
// 404
println(priceEndpoint.auth.unauthorizedStatus)
// 404

The default is 404 Not Found, chosen to hide that a protected endpoint exists (see Answering 401, 403 or 404). Neither value checks a signature, an expiry or a scope: ZIO Blocks ships no server interpreter that enforces them. The rest of the guide is the code that does.

warning

Endpoint#auth(AuthType.Bearer) on its own does not protect anything. Treat it as documentation of what your handler enforces, and keep the two in sync.

Reading the Bearer Token​

The HTTP model parses the Authorization header into one of four cases: Header.Authorization.Basic, Bearer, Digest or Unparsed. Request#header takes the header's companion object and returns an Option, so reading a bearer token is a collect on that Option:

def bearerToken(request: Request): Option[String] =
request.header(Header.Authorization).collect { case Header.Authorization.Bearer(token) => token }

To see how different headers come out, we build a few requests by hand, with no server involved:

def ordersRequest(authorization: Option[String]): Request = {
val request = Request.get(URL.parse("/orders").fold(error => sys.error(error), url => url))
authorization.fold(request)(value => request.addHeader("authorization", value))
}

A bearer header gives the token whatever the case of the scheme name; a Basic header, a header with no token and a missing header all give None:

println(bearerToken(ordersRequest(Some("Bearer eyJhbGciOiJIUzI1NiJ9.e30.c2ln"))))
// Some(eyJhbGciOiJIUzI1NiJ9.e30.c2ln)
println(bearerToken(ordersRequest(Some("bearer eyJhbGciOiJIUzI1NiJ9.e30.c2ln"))))
// Some(eyJhbGciOiJIUzI1NiJ9.e30.c2ln)
println(bearerToken(ordersRequest(Some("Basic cmVhZGVyOnNlY3JldA=="))))
// None
println(bearerToken(ordersRequest(Some("Bearer"))))
// None
println(bearerToken(ordersRequest(None)))
// None

Bearer with no token is not a valid header, and Headers#get skips values it cannot parse (see the headers reference), so it reads as a missing header. All three None cases get the same answer: a client that sent no usable token is asked to sign in.

warning

Read the token only from the Authorization header. Tokens in query strings end up in access logs, browser history and Referer headers.

Verifying the Token With Jwt.decode​

Before we can verify tokens, someone has to issue them. In production, an auth service does that. In this guide, a small helper plays the auth service and signs tokens with a shared HMAC secret, at a fixed time so that every result is the same on every run. Each token carries the user's roles in a custom roles claim:

object TestTokens {
// 2026-01-01T00:00:00Z. The API verifies tokens one minute later.
val now: Long = 1767225600L

// Sample data only. A real HS256 secret is at least 32 random bytes, loaded from a secret store.
val secret: String = "sample-bookstore-secret-32-bytes"

// Registered claims (iss, sub, aud, iat, exp) go in their own fields, never in `extra`.
def issue(
subject: String,
roles: List[String],
audience: String = "bookstore-api",
expiresAt: Option[Long] = Some(now + 3600),
key: String = secret
): String = {
val claims = JwtClaims(
iss = Some("bookstore-auth"),
sub = Some(subject),
aud = Some(JwtAudience.single(audience)),
iat = Some(now),
exp = expiresAt,
extra = Map("roles" -> JwtValue.Arr(Chunk.fromIterable(roles.map(role => JwtValue.Str(role)))))
)
Jwt.sign(claims, key.getBytes(UTF_8), Algorithm.HS256).fold(error => throw error, token => token)
}

val reader: String = issue("reader-1024", List("reader"))
val admin: String = issue("staff-0007", List("reader", "admin"))
val expired: String = issue("reader-1024", List("reader"), expiresAt = Some(now - 600))
val otherAudience: String = issue("reader-1024", List("reader"), audience = "partner-api")
val forged: String = issue("staff-0007", List("admin"), key = "attacker-guessed-secret-32-bytes")
val neverExpires: String = issue("reader-1024", List("reader"), expiresAt = None)
}
caution

Keep registered claims such as iss and exp out of JwtClaims#extra. If JwtClaims#extra also holds one, Jwt.sign writes the claim twice and verifiers read the JwtClaims#extra value.

On the API side, a handler should not work with raw claims. We turn a verified token into a Caller: who the user is and what roles they have. A Verifier is any function from a token to a Caller or a JwtError:

final case class Caller(subject: String, roles: Set[String])

type Verifier = String => Either[JwtError, Caller]

def rolesOf(claims: JwtClaims): Set[String] =
claims.extra.get("roles") match {
case Some(JwtValue.Arr(items)) => items.collect { case JwtValue.Str(role) => role }.toSet
case _ => Set.empty
}

def toCaller(claims: JwtClaims): Either[JwtError, Caller] =
(claims.sub, claims.exp) match {
case (_, None) => Left(JwtError.MissingClaim("exp"))
case (None, _) => Left(JwtError.MissingClaim("sub"))
case (Some(subject), _) => Right(Caller(subject, rolesOf(claims)))
}

def jwtVerifier(key: Array[Byte], options: JwtDecodeOptions): Verifier =
token => Jwt.decode(token, key, Algorithm.HS256, options).flatMap(toCaller)

Jwt.decode checks the algorithm, the signature (in constant time), and then the claims you configure. It checks exp only when the token has one, so a token without exp never expires. toCaller closes that gap by requiring both sub and exp.

We build the verifier with the options our API needs: tokens from bookstore-auth, meant for bookstore-api, with 30 seconds of tolerance for clock differences between servers. nowSeconds fixes the clock one minute after the tokens were issued:

val verify: Verifier = jwtVerifier(
TestTokens.secret.getBytes(UTF_8),
JwtDecodeOptions(
issuer = Some("bookstore-auth"),
expectedAudience = Some("bookstore-api"),
clockSkewSeconds = 30,
nowSeconds = Some(TestTokens.now + 60)
)
)

def report(name: String, token: String): Unit =
verify(token) match {
case Right(caller) => println(s"$name: accepted $caller")
case Left(error) => println(s"$name: rejected ${error.getClass.getSimpleName}: ${error.message}")
}

Running every test token through it accepts the two good ones and rejects each bad one with its own reason:

report("reader", TestTokens.reader)
// reader: accepted Caller(reader-1024,Set(reader))
report("admin", TestTokens.admin)
// admin: accepted Caller(staff-0007,Set(reader, admin))
report("expired", TestTokens.expired)
// expired: rejected ExpiredToken: JWT token expired at 1767225000 (now: 1767225660)
report("other audience", TestTokens.otherAudience)
// other audience: rejected InvalidClaim: Invalid JWT claim 'aud': expected bookstore-api but found partner-api
report("forged", TestTokens.forged)
// forged: rejected InvalidSignature: JWT signature verification failed for algorithm HS256
report("never expires", TestTokens.neverExpires)
// never expires: rejected MissingClaim: Missing JWT claim: exp
note

In production, leave nowSeconds unset. Jwt.decode then uses the system clock.

Answering 401, 403 or 404​

A protected endpoint has three ways to say no, and HTTP clients react differently to each. RFC 6750, which defines bearer tokens over HTTP, sets the rules:

SituationStatusWWW-Authenticate
No token, or another scheme such as Basic401 UnauthorizedBearer realm="bookstore"
Token is forged, expired, for another audience, ...401 UnauthorizedBearer realm="bookstore", error="invalid_token"
Token is valid, but the caller lacks the needed role403 ForbiddenBearer realm="bookstore", error="insufficient_scope"

A 401 means "who are you?": the client should sign in, or refresh its token, and try again. Every 401 must carry a WWW-Authenticate challenge that names the scheme. A 403 means "I know who you are, and the answer is no": a new token for the same user does not help.

Response.unauthorized and Response.forbidden carry no headers, so we build the three responses ourselves and add the typed Header.WWWAuthenticate with Response#addHeader:

val realm = "bookstore"

val missingToken: Response =
Response
.text(Status.Unauthorized, "Sign in to continue")
.addHeader(Header.WWWAuthenticate("Bearer", Map("realm" -> realm)))

val invalidToken: Response =
Response
.text(Status.Unauthorized, "The access token is invalid or expired")
.addHeader(Header.WWWAuthenticate("Bearer", Map("realm" -> realm, "error" -> "invalid_token")))

def missingRole(role: String): Response =
Response
.text(Status.Forbidden, s"This action needs the $role role")
.addHeader(Header.WWWAuthenticate("Bearer", Map("realm" -> realm, "error" -> "insufficient_scope")))

The challenge for an invalid token renders like this:

println(invalidToken.header(Header.WWWAuthenticate).map(_.renderedValue))
// Some(Bearer realm="bookstore", error="invalid_token")

The bodies are deliberately vague. Every bad token gets the same answer, so a caller cannot use the API to learn which check failed. The precise reason goes to the server log instead.

caution

Header.WWWAuthenticate writes each parameter value between double quotes as it is, without escaping, so a value that contains " produces an invalid header. Use fixed strings such as the ones above, and never copy a JwtError message or any request data into the challenge.

Some APIs answer 404 instead, as if the endpoint did not exist, which is why AuthType defaults to Status.NotFound. That hides admin routes from someone probing the API, but it costs a lot: clients can no longer tell an expired token from a wrong URL, so they cannot refresh and retry, and debugging gets harder. If you hide endpoints, answer 404 for every rejection of that endpoint, including the 403 case, or the difference between the answers gives the secret away. For an API whose routes are documented anyway, prefer 401 and 403.

Handing the Caller to the Handler​

Now we combine the pieces into one wrapper. authenticated takes a verifier, an optional role, and a handler that receives the verified Caller along with the request. It returns a plain Request => Response, so it fits any router, and the handler cannot run without a verified caller:

def authenticated(verify: Verifier, requiredRole: Option[String] = None)(
handler: (Caller, Request) => Response
): Request => Response =
request =>
bearerToken(request) match {
case None => missingToken
case Some(token) =>
verify(token) match {
case Left(error) =>
// Log why, never the token itself.
println(s"auth: rejected token: ${error.getClass.getSimpleName}")
invalidToken
case Right(caller) if requiredRole.forall(caller.roles.contains) => handler(caller, request)
case Right(caller) =>
println(s"auth: ${caller.subject} lacks role ${requiredRole.getOrElse("")}")
missingRole(requiredRole.getOrElse(""))
}
}

The rejection log names the kind of failure and, once the token is verified, the user. It never prints the token, which would let anyone who reads the log replay it. In a real service, send these lines to your logger instead of println.

The protected bookstore uses the caller's subject to pick the orders, so the ?user= parameter is gone, and it asks for the admin role before changing a price:

def bookstoreApp(verify: Verifier): Request => Response = {
val showOrders = authenticated(verify)((caller, _) => ordersOf(caller.subject))

def changePrice(id: String) =
authenticated(verify, requiredRole = Some("admin"))((_, request) => setPrice(id, request.body.text))

request =>
(request.method, request.path.segments.toList) match {
case (Method.GET, List("books")) => listBooks
case (Method.GET, List("orders")) => showOrders(request)
case (Method.PUT, List("books", id, "price")) => changePrice(id)(request)
case _ => notFound(request)
}
}

When we call it with no token, as a reader and as an admin, the server's log lines appear just before the response they explain:

JdkHttp.withServer(bookstoreApp(verify)) { port =>
Client.call(port, "GET", "/books")
Client.call(port, "GET", "/orders")
Client.call(port, "GET", "/orders", authorization = s"Bearer ${TestTokens.reader}")
Client.call(port, "PUT", "/books/1/price", "3999", authorization = s"Bearer ${TestTokens.reader}")
Client.call(port, "PUT", "/books/1/price", "3999", authorization = s"Bearer ${TestTokens.admin}")
}
// GET /books -> 200 [text/plain; charset=UTF-8, 93 bytes]
// 1. Zionomicon $45.00
// 2. Programming in Scala $59.99
// 3. Functional Programming in Scala $49.99
// GET /orders -> 401 [text/plain; charset=UTF-8, 19 bytes]
// www-authenticate: Bearer realm="bookstore"
// Sign in to continue
// GET /orders -> 200 [text/plain; charset=UTF-8, 66 bytes]
// Orders of reader-1024: Zionomicon, Functional Programming in Scala
// auth: reader-1024 lacks role admin
// PUT /books/1/price -> 403 [text/plain; charset=UTF-8, 32 bytes]
// www-authenticate: Bearer realm="bookstore", error="insufficient_scope"
// This action needs the admin role
// PUT /books/1/price -> 200 [text/plain; charset=UTF-8, 27 bytes]
// Zionomicon now costs $39.99

The public route still answers everyone, a missing token gets a challenge, the reader sees only their own orders and gets 403 on the admin route, and the admin can change the price.

Loading the Key From Configuration​

The signing key, the issuer and the audience belong in configuration, not in code. We describe them as a case class and keep the HMAC secret in a Secret, whose toString is always <secret>:

final case class AuthConfig(issuer: String, audience: String, hmacSecret: Secret)

object AuthConfig {
implicit val schema: Schema[AuthConfig] = Schema.derived
}

Secret.unwrap is the only way to read the value, and we call it exactly once, to turn the secret into key bytes. HS256 needs a key of at least 32 bytes, and Jwt.decode rejects a shorter one on every request, so we check the length once at startup and refuse to start instead:

def verifierFrom(config: AuthConfig, nowSeconds: Option[Long] = None): Either[String, Verifier] = {
val key = Secret.unwrap(config.hmacSecret).getBytes(UTF_8)
if (key.length < 32) Left(s"hmacSecret has ${key.length} bytes; HS256 needs at least 32")
else
Right(
jwtVerifier(
key,
JwtDecodeOptions(
issuer = Some(config.issuer),
expectedAudience = Some(config.audience),
clockSkewSeconds = 30,
nowSeconds = nowSeconds
)
)
)
}

To keep the output stable, we load the configuration from a map with ConfigSource.fromMap. In production the same keys come from environment variables or a YAML or HOCON file, as Build a Typed Configuration Loader shows. The startWith helper plays the startup of our service: it loads the configuration, builds the verifier and checks the reader's token:

val settings: Map[String, String] = Map(
"issuer" -> "bookstore-auth",
"audience" -> "bookstore-api",
"hmacSecret" -> TestTokens.secret
)

def startWith(settings: Map[String, String]): Unit =
Config.load[AuthConfig](ConfigSource.fromMap(settings, "env")) match {
case Left(errors) => errors.foreach(error => println(s"cannot start: ${error.message}"))
case Right(config) =>
println(s"loaded $config")
verifierFrom(config, nowSeconds = Some(TestTokens.now + 60)) match {
case Left(problem) => println(s"cannot start: $problem")
case Right(verify) => println(s"reader token: ${verify(TestTokens.reader)}")
}
}

Starting with the full settings, with no secret, and with a secret that is too short:

startWith(settings)
// loaded AuthConfig(bookstore-auth,bookstore-api,<secret>)
// reader token: Right(Caller(reader-1024,Set(reader)))
startWith(settings - "hmacSecret")
// cannot start: Missing required key 'hmacSecret' in source 'env'
startWith(settings.updated("hmacSecret", "too-short"))
// loaded AuthConfig(bookstore-auth,bookstore-api,<secret>)
// cannot start: hmacSecret has 9 bytes; HS256 needs at least 32

The loaded configuration prints with <secret> in place of the key, so it is safe to log at startup.

tip

For an RSA, EC or EdDSA key pair, the API needs only the public key, which is not secret: load it as a Base64-encoded X.509 DER String, decode it with java.util.Base64, and pass the matching Algorithm to Jwt.decode. Call JvmJwtCryptoBackend.install() once at startup to enable these algorithms. How to Issue and Verify JWTs shows the key formats. The module has no JWK or JWKS support, so it cannot fetch keys from an identity provider's key-set URL: fetch and convert them yourself.

Calling the Protected API​

With the verifier loaded from configuration, we can send the API every kind of request it will meet in practice: no token, a Basic header, each bad token from the test set, a reader's token and an admin's token:

val configured: Verifier = Config
.load[AuthConfig](ConfigSource.fromMap(settings, "env"))
.left
.map(_.map(_.message).mkString("; "))
.flatMap(verifierFrom(_, nowSeconds = Some(TestTokens.now + 60)))
.fold(sys.error, identity)

def bearer(token: String): String = s"Bearer $token"

Serving the bookstore with this verifier and calling GET /orders once per case:

JdkHttp.withServer(bookstoreApp(configured)) { port =>
Client.call(port, "GET", "/orders")
Client.call(port, "GET", "/orders", authorization = "Basic cmVhZGVyOnNlY3JldA==")
Client.call(port, "GET", "/orders", authorization = bearer(TestTokens.forged))
Client.call(port, "GET", "/orders", authorization = bearer(TestTokens.expired))
Client.call(port, "GET", "/orders", authorization = bearer(TestTokens.otherAudience))
Client.call(port, "GET", "/orders", authorization = bearer(TestTokens.neverExpires))
Client.call(port, "GET", "/orders", authorization = bearer(TestTokens.reader))
}
// GET /orders -> 401 [text/plain; charset=UTF-8, 19 bytes]
// www-authenticate: Bearer realm="bookstore"
// Sign in to continue
// GET /orders -> 401 [text/plain; charset=UTF-8, 19 bytes]
// www-authenticate: Bearer realm="bookstore"
// Sign in to continue
// auth: rejected token: InvalidSignature
// GET /orders -> 401 [text/plain; charset=UTF-8, 38 bytes]
// www-authenticate: Bearer realm="bookstore", error="invalid_token"
// The access token is invalid or expired
// auth: rejected token: ExpiredToken
// GET /orders -> 401 [text/plain; charset=UTF-8, 38 bytes]
// www-authenticate: Bearer realm="bookstore", error="invalid_token"
// The access token is invalid or expired
// auth: rejected token: InvalidClaim
// GET /orders -> 401 [text/plain; charset=UTF-8, 38 bytes]
// www-authenticate: Bearer realm="bookstore", error="invalid_token"
// The access token is invalid or expired
// auth: rejected token: MissingClaim
// GET /orders -> 401 [text/plain; charset=UTF-8, 38 bytes]
// www-authenticate: Bearer realm="bookstore", error="invalid_token"
// The access token is invalid or expired
// GET /orders -> 200 [text/plain; charset=UTF-8, 66 bytes]
// Orders of reader-1024: Zionomicon, Functional Programming in Scala

A request with no usable token gets a plain challenge. Each bad token gets the same invalid_token answer, while the log records the precise reason: a forged signature, an expired token, the wrong audience, or a token with no expiry.

What This Code Does Not Do​

The wrapper verifies tokens; it is not a complete security setup. Before you rely on it, check these points:

  • Use HTTPS. A bearer token works for whoever holds it. Over plain HTTP, anyone on the network path can copy it. The loopback server in this guide is for examples only.
  • No revocation. A signed token stays valid until it expires. Keep exp short, minutes rather than days, and refresh tokens on the client.
  • Roles are only as good as the issuer. The API trusts the roles claim because the token is signed. Anyone who holds the signing key can mint an admin token, so prefer an asymmetric key pair when another service issues tokens: the API then holds only the public key.

The verifier also knows only one key, so rotating the signing key needs a little more code. The module does not pick a key by the header's kid, so we choose the key ourselves: during a rotation, a verifier tries the new key and then the old one until every token signed with the old key has expired:

def firstAccepting(verifiers: List[Verifier]): Verifier =
token => {
val results = verifiers.map(verify => verify(token))
results.collectFirst { case accepted @ Right(_) => accepted }.getOrElse(results.head)
}

Putting It Together​

The complete example loads the configuration, builds the verifier, serves the protected bookstore, and calls it with every token from this guide. It uses four shared files from Running the Examples: the adapter JdkHttp.scala, the test client Client.scala, the model Bookstore.scala and the token issuer TestTokens.scala:

jwt-examples/src/main/scala/protectendpointswithjwt/CompleteExample.scala
package protectendpointswithjwt

import java.nio.charset.StandardCharsets.UTF_8
import zio.blocks.config.{Config, ConfigSource, Secret}
import zio.blocks.jwt._
import zio.blocks.schema.Schema
import zio.http._

import Bookstore._

/**
* How to Protect an Endpoint with JWT Bearer Tokens — Complete Example
*
* A bookstore API with a public `GET /books`, a `GET /orders` for signed-in
* users and a `PUT /books/{id}/price` for admins. The HMAC secret is loaded
* from configuration as a `Secret`, every bearer token is verified with
* `Jwt.decode`, and failures are answered with 401 or 403 and a
* `WWW-Authenticate: Bearer` challenge. The program calls the API with no
* token, bad tokens and good tokens, then stops the server.
*
* Run with: cd jwt-examples && sbt "runMain
* protectendpointswithjwt.CompleteExample"
*/
object CompleteExample {

final case class AuthConfig(issuer: String, audience: String, hmacSecret: Secret)

object AuthConfig {
implicit val schema: Schema[AuthConfig] = Schema.derived
}

final case class Caller(subject: String, roles: Set[String])

type Verifier = String => Either[JwtError, Caller]

def rolesOf(claims: JwtClaims): Set[String] =
claims.extra.get("roles") match {
case Some(JwtValue.Arr(items)) => items.collect { case JwtValue.Str(role) => role }.toSet
case _ => Set.empty
}

def toCaller(claims: JwtClaims): Either[JwtError, Caller] =
(claims.sub, claims.exp) match {
case (_, None) => Left(JwtError.MissingClaim("exp"))
case (None, _) => Left(JwtError.MissingClaim("sub"))
case (Some(subject), _) => Right(Caller(subject, rolesOf(claims)))
}

def verifierFrom(config: AuthConfig, nowSeconds: Option[Long] = None): Either[String, Verifier] = {
val key = Secret.unwrap(config.hmacSecret).getBytes(UTF_8)
val options = JwtDecodeOptions(
issuer = Some(config.issuer),
expectedAudience = Some(config.audience),
clockSkewSeconds = 30,
nowSeconds = nowSeconds
)
if (key.length < 32) Left(s"hmacSecret has ${key.length} bytes; HS256 needs at least 32")
else Right(token => Jwt.decode(token, key, Algorithm.HS256, options).flatMap(toCaller))
}

val realm = "bookstore"

val missingToken: Response =
Response
.text(Status.Unauthorized, "Sign in to continue")
.addHeader(Header.WWWAuthenticate("Bearer", Map("realm" -> realm)))

val invalidToken: Response =
Response
.text(Status.Unauthorized, "The access token is invalid or expired")
.addHeader(Header.WWWAuthenticate("Bearer", Map("realm" -> realm, "error" -> "invalid_token")))

def missingRole(role: String): Response =
Response
.text(Status.Forbidden, s"This action needs the $role role")
.addHeader(Header.WWWAuthenticate("Bearer", Map("realm" -> realm, "error" -> "insufficient_scope")))

def bearerToken(request: Request): Option[String] =
request.header(Header.Authorization).collect { case Header.Authorization.Bearer(token) => token }

def authenticated(verify: Verifier, requiredRole: Option[String] = None)(
handler: (Caller, Request) => Response
): Request => Response =
request =>
bearerToken(request) match {
case None => missingToken
case Some(token) =>
verify(token) match {
case Left(error) =>
// Log why, never the token itself.
println(s"auth: rejected token: ${error.getClass.getSimpleName}")
invalidToken
case Right(caller) if requiredRole.forall(caller.roles.contains) => handler(caller, request)
case Right(caller) =>
println(s"auth: ${caller.subject} lacks role ${requiredRole.getOrElse("")}")
missingRole(requiredRole.getOrElse(""))
}
}

def bookstoreApp(verify: Verifier): Request => Response = {
val showOrders = authenticated(verify)((caller, _) => ordersOf(caller.subject))

def changePrice(id: String) =
authenticated(verify, requiredRole = Some("admin"))((_, request) => setPrice(id, request.body.text))

request =>
(request.method, request.path.segments.toList) match {
case (Method.GET, List("books")) => listBooks
case (Method.GET, List("orders")) => showOrders(request)
case (Method.PUT, List("books", id, "price")) => changePrice(id)(request)
case _ => notFound(request)
}
}

// In production these values come from the environment or a config file.
val settings: Map[String, String] = Map(
"issuer" -> "bookstore-auth",
"audience" -> "bookstore-api",
"hmacSecret" -> TestTokens.secret
)

def main(args: Array[String]): Unit = {
val config = Config.load[AuthConfig](ConfigSource.fromMap(settings, "env")) match {
case Right(loaded) => loaded
case Left(errors) => sys.error(errors.map(_.message).mkString("; "))
}
println(s"loaded $config")

// A fixed clock keeps the output stable; leave `nowSeconds` unset in production.
val verify = verifierFrom(config, nowSeconds = Some(TestTokens.now + 60)).fold(sys.error, identity)

JdkHttp.withServer(bookstoreApp(verify)) { port =>
def bearer(token: String) = s"Bearer $token"

Client.call(port, "GET", "/books")
Client.call(port, "GET", "/orders")
Client.call(port, "GET", "/orders", authorization = "Basic cmVhZGVyOnNlY3JldA==")
Client.call(port, "GET", "/orders", authorization = bearer(TestTokens.forged))
Client.call(port, "GET", "/orders", authorization = bearer(TestTokens.expired))
Client.call(port, "GET", "/orders", authorization = bearer(TestTokens.otherAudience))
Client.call(port, "GET", "/orders", authorization = bearer(TestTokens.neverExpires))
Client.call(port, "GET", "/orders", authorization = bearer(TestTokens.reader))
Client.call(port, "PUT", "/books/1/price", "3999", authorization = bearer(TestTokens.reader))
Client.call(port, "PUT", "/books/1/price", "3999", authorization = bearer(TestTokens.admin))
}
}
}

Running the Examples​

All code from this guide is available as runnable examples in the jwt-examples directory. It is a standalone sbt build that uses the published ZIO Blocks artifacts. To run them, clone the repository and move into that directory:

git clone https://github.com/zio/zio-blocks.git
cd zio-blocks/jwt-examples

The examples share four files. JdkHttp.scala is the adapter from the JDK HTTP server guide, unchanged except for its package. Client.scala is that guide's test client with two additions: an authorization parameter, and printing the WWW-Authenticate header:

jwt-examples/src/main/scala/protectendpointswithjwt/JdkHttp.scala
jwt-examples/src/main/scala/protectendpointswithjwt/JdkHttp.scala
package protectendpointswithjwt

import com.sun.net.httpserver.{HttpExchange, HttpHandler, HttpServer}
import java.net.{InetAddress, InetSocketAddress}
import scala.jdk.CollectionConverters._
import scala.util.control.NonFatal
import zio.http._

/**
* How to Serve http-model Requests with the JDK HTTP Server — the adapter
*
* Glue between the JDK's built-in `com.sun.net.httpserver.HttpServer` and a
* plain `Request => Response` function. It is meant for examples, tools and
* small services, not for production traffic.
*/
object JdkHttp {

/**
* Reads the method, URL, headers and body of an exchange into a `Request`.
* Returns `Left` with a ready-made error response when the method is unknown
* (501) or the request target is not a valid URL (400).
*/
def toRequest(exchange: HttpExchange): Either[Response, Request] = {
val uri = exchange.getRequestURI
val target = uri.getRawPath + Option(uri.getRawQuery).fold("")("?" + _)
for {
method <- Method.fromString(exchange.getRequestMethod).toRight(Response(Status.NotImplemented))
url <- URL.parse(target).left.map(error => Response.text(Status.BadRequest, error))
} yield {
val headers = exchange.getRequestHeaders.asScala.foldLeft(Headers.empty) { case (acc, (name, values)) =>
values.asScala.foldLeft(acc)((withValue, value) => withValue.add(name, value))
}
val contentType =
headers.get(Header.ContentType).map(_.value).getOrElse(ContentType.`application/octet-stream`)
val body = Body.fromArray(exchange.getRequestBody.readAllBytes(), contentType)
val version = Version.fromString(exchange.getProtocol).getOrElse(Version.`HTTP/1.1`)
Request(method, url, headers, body, version)
}
}

/**
* Writes the status, headers and body of a `Response` to an exchange. The JDK
* server computes `Content-Length` itself, so the response's own
* `content-length` and `transfer-encoding` headers are skipped.
*/
def writeResponse(exchange: HttpExchange, response: Response): Unit = {
val out = exchange.getResponseHeaders
response.headers.toList.foreach { case (name, value) =>
if (name != "content-length" && name != "transfer-encoding") out.add(name, value)
}
val bytes = response.body.toArray
if (bytes.nonEmpty && !response.headers.has("content-type"))
out.set("content-type", response.body.contentType.render)
val code = response.status.code
val isHead = exchange.getRequestMethod == "HEAD"
if (isHead && bytes.nonEmpty) out.set("content-length", bytes.length.toString)
val noBody = isHead || code == 204 || code == 304 || bytes.isEmpty
exchange.sendResponseHeaders(code, if (noBody) -1L else bytes.length.toLong)
if (!noBody) exchange.getResponseBody.write(bytes)
}

/**
* Turns an application into an `HttpHandler`. A `HEAD` request runs the `GET`
* route and drops the body; an exception in the application becomes a 500
* response; the exchange is closed on every path.
*/
def handler(app: Request => Response): HttpHandler = exchange =>
try {
val response =
try
toRequest(exchange) match {
case Left(error) => error
case Right(request) if request.method == Method.HEAD => app(request.copy(method = Method.GET))
case Right(request) => app(request)
}
catch {
case NonFatal(error) =>
System.err.println(s"Handler failed: $error")
Response.text(Status.InternalServerError, "Internal Server Error")
}
writeResponse(exchange, response)
} finally exchange.close()

/**
* Starts a server on the loopback interface. Port 0 asks the operating system
* for a free port; read it back with `server.getAddress.getPort`.
*/
def start(app: Request => Response, port: Int = 0): HttpServer = {
val server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress, port), 0)
server.createContext("/", handler(app))
server.start()
server
}

/**
* Starts a server on a free loopback port, passes the port to `use`, and
* stops the server when `use` returns or throws.
*/
def withServer[A](app: Request => Response)(use: Int => A): A = {
val server = start(app)
try use(server.getAddress.getPort)
finally server.stop(0)
}
}
jwt-examples/src/main/scala/protectendpointswithjwt/Client.scala
jwt-examples/src/main/scala/protectendpointswithjwt/Client.scala
package protectendpointswithjwt

import java.net.URI
import java.net.http.{HttpClient, HttpRequest, HttpResponse}

/**
* How to Serve http-model Requests with the JDK HTTP Server — a test client
*
* Sends one request with the JDK's `java.net.http.HttpClient` and prints the
* status code, the `Content-Type` and `Content-Length` headers, and the body.
*
* Copied from the JDK HTTP server guide with two additions for this guide: an
* optional `Authorization` header value to send, and the `WWW-Authenticate`
* header printed when the response has one.
*/
object Client {
private val client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build()

def call(port: Int, method: String, target: String, body: String = "", authorization: String = ""): Unit = {
val plain = HttpRequest.newBuilder(URI.create(s"http://127.0.0.1:$port$target"))
val builder = if (authorization.isEmpty) plain else plain.header("authorization", authorization)
val request =
if (body.isEmpty) builder.method(method, HttpRequest.BodyPublishers.noBody()).build()
else
builder
.method(method, HttpRequest.BodyPublishers.ofString(body))
.header("content-type", "application/json")
.build()
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
def header(name: String): String = response.headers.firstValue(name).orElse("none")
println(s"$method $target -> ${response.statusCode} [${header("content-type")}, ${header("content-length")} bytes]")
response.headers.firstValue("www-authenticate").ifPresent(value => println(s"www-authenticate: $value"))
if (response.body.nonEmpty) println(response.body)
}
}
jwt-examples/src/main/scala/protectendpointswithjwt/Bookstore.scala
jwt-examples/src/main/scala/protectendpointswithjwt/Bookstore.scala
package protectendpointswithjwt

import zio.http._

/**
* How to Protect an Endpoint with JWT Bearer Tokens — the bookstore model
*
* Three Scala books, the orders of two fictional users, and the plain responses
* that the bookstore API sends. Nothing here knows about authentication.
*/
final case class Book(id: Int, title: String, priceCents: Long)

object Bookstore {
val books: List[Book] = List(
Book(1, "Zionomicon", 4500),
Book(2, "Programming in Scala", 5999),
Book(3, "Functional Programming in Scala", 4999)
)

// Orders per user, keyed by the `sub` claim of their token.
val orders: Map[String, List[String]] = Map(
"reader-1024" -> List("Zionomicon", "Functional Programming in Scala"),
"staff-0007" -> List("Programming in Scala")
)

def price(cents: Long): String = f"$$${cents / 100}%d.${cents % 100}%02d"

def listBooks: Response =
Response.text(books.map(book => s"${book.id}. ${book.title} ${price(book.priceCents)}").mkString("\n"))

def ordersOf(user: String): Response =
Response.text(s"Orders of $user: ${orders.getOrElse(user, Nil).mkString(", ")}")

// Answers with the new price; a real store would also save it.
def setPrice(id: String, body: String): Response =
(books.find(_.id.toString == id), body.trim.toLongOption.filter(_ > 0)) match {
case (None, _) => Response.text(Status.NotFound, s"No book with id $id")
case (_, None) => Response.text(Status.BadRequest, "The body must be a price in cents")
case (Some(book), Some(cents)) => Response.text(s"${book.title} now costs ${price(cents)}")
}

def notFound(request: Request): Response =
Response.text(Status.NotFound, s"No route for ${request.method.name} ${request.path.encode}")
}
jwt-examples/src/main/scala/protectendpointswithjwt/TestTokens.scala
jwt-examples/src/main/scala/protectendpointswithjwt/TestTokens.scala
package protectendpointswithjwt

import java.nio.charset.StandardCharsets.UTF_8
import zio.blocks.chunk.Chunk
import zio.blocks.jwt._

/**
* How to Protect an Endpoint with JWT Bearer Tokens — test tokens
*
* Plays the auth service: signs the tokens that the examples send to the
* bookstore API. Every token is issued at a fixed time, so the results are the
* same on every run.
*/
object TestTokens {

// 2026-01-01T00:00:00Z. The API verifies tokens one minute later.
val now: Long = 1767225600L

// Sample data only. A real HS256 secret is at least 32 random bytes, loaded from a secret store.
val secret: String = "sample-bookstore-secret-32-bytes"

// Registered claims (iss, sub, aud, iat, exp) go in their own fields, never in `extra`.
def issue(
subject: String,
roles: List[String],
audience: String = "bookstore-api",
expiresAt: Option[Long] = Some(now + 3600),
key: String = secret
): String = {
val claims = JwtClaims(
iss = Some("bookstore-auth"),
sub = Some(subject),
aud = Some(JwtAudience.single(audience)),
iat = Some(now),
exp = expiresAt,
extra = Map("roles" -> JwtValue.Arr(Chunk.fromIterable(roles.map(role => JwtValue.Str(role)))))
)
Jwt.sign(claims, key.getBytes(UTF_8), Algorithm.HS256).fold(error => throw error, token => token)
}

val reader: String = issue("reader-1024", List("reader"))
val admin: String = issue("staff-0007", List("reader", "admin"))
val expired: String = issue("reader-1024", List("reader"), expiresAt = Some(now - 600))
val otherAudience: String = issue("reader-1024", List("reader"), audience = "partner-api")
val forged: String = issue("staff-0007", List("admin"), key = "attacker-guessed-secret-32-bytes")
val neverExpires: String = issue("reader-1024", List("reader"), expiresAt = None)
}

Serve the Unprotected API​

The first example serves the bookstore with no authentication and shows that anyone can read any user's orders and change a price:

jwt-examples/src/main/scala/protectendpointswithjwt/Step1UnprotectedApiExample.scala
jwt-examples/src/main/scala/protectendpointswithjwt/Step1UnprotectedApiExample.scala
package protectendpointswithjwt

import zio.http._

import Bookstore._

/**
* How to Protect an Endpoint with JWT Bearer Tokens: Step 1 — The Unprotected
* API
*
* Serves the bookstore with no authentication: the caller names whose orders to
* show, and anyone can change a price.
*
* Run with: cd jwt-examples && sbt "runMain
* protectendpointswithjwt.Step1UnprotectedApiExample"
*/
object Step1UnprotectedApiExample {

def app(request: Request): Response =
(request.method, request.path.segments.toList) match {
case (Method.GET, List("books")) => listBooks
case (Method.GET, List("orders")) => ordersOf(request.queryParams.getFirst("user").getOrElse(""))
case (Method.PUT, List("books", id, "price")) => setPrice(id, request.body.text)
case _ => notFound(request)
}

def main(args: Array[String]): Unit =
JdkHttp.withServer(app) { port =>
Client.call(port, "GET", "/books")
Client.call(port, "GET", "/orders?user=reader-1024")
Client.call(port, "PUT", "/books/1/price", "1")
}
}

To run it:

sbt "runMain protectendpointswithjwt.Step1UnprotectedApiExample"

Read the Bearer Token​

The second example reads the token from five different Authorization headers:

jwt-examples/src/main/scala/protectendpointswithjwt/Step2ReadBearerTokenExample.scala
jwt-examples/src/main/scala/protectendpointswithjwt/Step2ReadBearerTokenExample.scala
package protectendpointswithjwt

import zio.http._

/**
* How to Protect an Endpoint with JWT Bearer Tokens: Step 2 — Reading the
* Bearer Token
*
* Reads the token from the `Authorization` header with the typed header API,
* for a Bearer header, a Basic header, a header with no token and no header.
*
* Run with: cd jwt-examples && sbt "runMain
* protectendpointswithjwt.Step2ReadBearerTokenExample"
*/
object Step2ReadBearerTokenExample {

def bearerToken(request: Request): Option[String] =
request.header(Header.Authorization).collect { case Header.Authorization.Bearer(token) => token }

def ordersRequest(authorization: Option[String]): Request = {
val request = Request.get(URL.parse("/orders").fold(error => sys.error(error), url => url))
authorization.fold(request)(value => request.addHeader("authorization", value))
}

def main(args: Array[String]): Unit = {
println(bearerToken(ordersRequest(Some("Bearer eyJhbGciOiJIUzI1NiJ9.e30.c2ln"))))
println(bearerToken(ordersRequest(Some("bearer eyJhbGciOiJIUzI1NiJ9.e30.c2ln"))))
println(bearerToken(ordersRequest(Some("Basic cmVhZGVyOnNlY3JldA=="))))
println(bearerToken(ordersRequest(Some("Bearer"))))
println(bearerToken(ordersRequest(None)))
}
}

To run it:

sbt "runMain protectendpointswithjwt.Step2ReadBearerTokenExample"

Verify the Token​

The third example verifies every test token and prints the caller or the reason for the rejection:

jwt-examples/src/main/scala/protectendpointswithjwt/Step3VerifyTokenExample.scala
jwt-examples/src/main/scala/protectendpointswithjwt/Step3VerifyTokenExample.scala
package protectendpointswithjwt

import java.nio.charset.StandardCharsets.UTF_8
import zio.blocks.jwt._

/**
* How to Protect an Endpoint with JWT Bearer Tokens: Step 3 — Verifying the
* Token
*
* Verifies each test token with `Jwt.decode`, checks issuer, audience and
* expiry against a fixed clock, requires `sub` and `exp`, and turns the claims
* into a `Caller`.
*
* Run with: cd jwt-examples && sbt "runMain
* protectendpointswithjwt.Step3VerifyTokenExample"
*/
object Step3VerifyTokenExample {

final case class Caller(subject: String, roles: Set[String])

type Verifier = String => Either[JwtError, Caller]

def rolesOf(claims: JwtClaims): Set[String] =
claims.extra.get("roles") match {
case Some(JwtValue.Arr(items)) => items.collect { case JwtValue.Str(role) => role }.toSet
case _ => Set.empty
}

def toCaller(claims: JwtClaims): Either[JwtError, Caller] =
(claims.sub, claims.exp) match {
case (_, None) => Left(JwtError.MissingClaim("exp"))
case (None, _) => Left(JwtError.MissingClaim("sub"))
case (Some(subject), _) => Right(Caller(subject, rolesOf(claims)))
}

def jwtVerifier(key: Array[Byte], options: JwtDecodeOptions): Verifier =
token => Jwt.decode(token, key, Algorithm.HS256, options).flatMap(toCaller)

val verify: Verifier = jwtVerifier(
TestTokens.secret.getBytes(UTF_8),
JwtDecodeOptions(
issuer = Some("bookstore-auth"),
expectedAudience = Some("bookstore-api"),
clockSkewSeconds = 30,
nowSeconds = Some(TestTokens.now + 60)
)
)

def report(name: String, token: String): Unit =
verify(token) match {
case Right(caller) => println(s"$name: accepted $caller")
case Left(error) => println(s"$name: rejected ${error.getClass.getSimpleName}: ${error.message}")
}

def main(args: Array[String]): Unit = {
report("reader", TestTokens.reader)
report("admin", TestTokens.admin)
report("expired", TestTokens.expired)
report("other audience", TestTokens.otherAudience)
report("forged", TestTokens.forged)
report("never expires", TestTokens.neverExpires)
}
}

To run it:

sbt "runMain protectendpointswithjwt.Step3VerifyTokenExample"

Protect the Routes​

The fourth example adds the 401 and 403 responses and the authenticated wrapper, and calls the protected API:

jwt-examples/src/main/scala/protectendpointswithjwt/Step4ProtectedRoutesExample.scala
jwt-examples/src/main/scala/protectendpointswithjwt/Step4ProtectedRoutesExample.scala
package protectendpointswithjwt

import zio.blocks.jwt._
import zio.http._

import Bookstore._
import Step2ReadBearerTokenExample.bearerToken
import Step3VerifyTokenExample.{Caller, Verifier, verify}

/**
* How to Protect an Endpoint with JWT Bearer Tokens: Step 4 — Protecting the
* Routes
*
* Wraps the handlers of `GET /orders` and `PUT /books/{id}/price` in
* `authenticated`, which answers 401 with a `WWW-Authenticate: Bearer`
* challenge for a missing or bad token, 403 when the caller lacks the role, and
* otherwise passes the verified caller to the handler.
*
* Run with: cd jwt-examples && sbt "runMain
* protectendpointswithjwt.Step4ProtectedRoutesExample"
*/
object Step4ProtectedRoutesExample {

val realm = "bookstore"

val missingToken: Response =
Response
.text(Status.Unauthorized, "Sign in to continue")
.addHeader(Header.WWWAuthenticate("Bearer", Map("realm" -> realm)))

val invalidToken: Response =
Response
.text(Status.Unauthorized, "The access token is invalid or expired")
.addHeader(Header.WWWAuthenticate("Bearer", Map("realm" -> realm, "error" -> "invalid_token")))

def missingRole(role: String): Response =
Response
.text(Status.Forbidden, s"This action needs the $role role")
.addHeader(Header.WWWAuthenticate("Bearer", Map("realm" -> realm, "error" -> "insufficient_scope")))

def authenticated(verify: Verifier, requiredRole: Option[String] = None)(
handler: (Caller, Request) => Response
): Request => Response =
request =>
bearerToken(request) match {
case None => missingToken
case Some(token) =>
verify(token) match {
case Left(error) =>
// Log why, never the token itself.
println(s"auth: rejected token: ${error.getClass.getSimpleName}")
invalidToken
case Right(caller) if requiredRole.forall(caller.roles.contains) => handler(caller, request)
case Right(caller) =>
println(s"auth: ${caller.subject} lacks role ${requiredRole.getOrElse("")}")
missingRole(requiredRole.getOrElse(""))
}
}

def bookstoreApp(verify: Verifier): Request => Response = {
val showOrders = authenticated(verify)((caller, _) => ordersOf(caller.subject))

def changePrice(id: String) =
authenticated(verify, requiredRole = Some("admin"))((_, request) => setPrice(id, request.body.text))

request =>
(request.method, request.path.segments.toList) match {
case (Method.GET, List("books")) => listBooks
case (Method.GET, List("orders")) => showOrders(request)
case (Method.PUT, List("books", id, "price")) => changePrice(id)(request)
case _ => notFound(request)
}
}

def main(args: Array[String]): Unit =
JdkHttp.withServer(bookstoreApp(verify)) { port =>
Client.call(port, "GET", "/books")
Client.call(port, "GET", "/orders")
Client.call(port, "GET", "/orders", authorization = s"Bearer ${TestTokens.reader}")
Client.call(port, "PUT", "/books/1/price", "3999", authorization = s"Bearer ${TestTokens.reader}")
Client.call(port, "PUT", "/books/1/price", "3999", authorization = s"Bearer ${TestTokens.admin}")
}
}

To run it:

sbt "runMain protectendpointswithjwt.Step4ProtectedRoutesExample"

Load the Key From Configuration​

The fifth example loads the issuer, the audience and the secret with Config.load, and refuses to start without a usable secret:

jwt-examples/src/main/scala/protectendpointswithjwt/Step5KeyFromConfigExample.scala
jwt-examples/src/main/scala/protectendpointswithjwt/Step5KeyFromConfigExample.scala
package protectendpointswithjwt

import java.nio.charset.StandardCharsets.UTF_8
import zio.blocks.config.{Config, ConfigSource, Secret}
import zio.blocks.jwt._
import zio.blocks.schema.Schema

import Step3VerifyTokenExample.{Verifier, jwtVerifier}

/**
* How to Protect an Endpoint with JWT Bearer Tokens: Step 5 — Loading the Key
* From Configuration
*
* Loads the issuer, the audience and the HMAC secret with `Config.load`, keeps
* the secret in a `Secret` so it never shows up in logs, and refuses to start
* with a secret shorter than HS256 needs.
*
* Run with: cd jwt-examples && sbt "runMain
* protectendpointswithjwt.Step5KeyFromConfigExample"
*/
object Step5KeyFromConfigExample {

final case class AuthConfig(issuer: String, audience: String, hmacSecret: Secret)

object AuthConfig {
implicit val schema: Schema[AuthConfig] = Schema.derived
}

def verifierFrom(config: AuthConfig, nowSeconds: Option[Long] = None): Either[String, Verifier] = {
val key = Secret.unwrap(config.hmacSecret).getBytes(UTF_8)
if (key.length < 32) Left(s"hmacSecret has ${key.length} bytes; HS256 needs at least 32")
else
Right(
jwtVerifier(
key,
JwtDecodeOptions(
issuer = Some(config.issuer),
expectedAudience = Some(config.audience),
clockSkewSeconds = 30,
nowSeconds = nowSeconds
)
)
)
}

// In production these values come from the environment or a config file; see the typed config guide.
val settings: Map[String, String] = Map(
"issuer" -> "bookstore-auth",
"audience" -> "bookstore-api",
"hmacSecret" -> TestTokens.secret
)

def startWith(settings: Map[String, String]): Unit =
Config.load[AuthConfig](ConfigSource.fromMap(settings, "env")) match {
case Left(errors) => errors.foreach(error => println(s"cannot start: ${error.message}"))
case Right(config) =>
println(s"loaded $config")
verifierFrom(config, nowSeconds = Some(TestTokens.now + 60)) match {
case Left(problem) => println(s"cannot start: $problem")
case Right(verify) => println(s"reader token: ${verify(TestTokens.reader)}")
}
}

def main(args: Array[String]): Unit = {
startWith(settings)
startWith(settings - "hmacSecret")
startWith(settings.updated("hmacSecret", "too-short"))
}
}

To run it:

sbt "runMain protectendpointswithjwt.Step5KeyFromConfigExample"

The Complete Example​

To run the complete example shown under Putting It Together:

sbt "runMain protectendpointswithjwt.CompleteExample"

Compile All Examples​

To compile all the examples without running them:

sbt compile

Going Further​

  • How to Issue and Verify JWTs covers asymmetric keys, PEM files, hostile-token limits and every JwtError case.
  • The JWT reference lists every algorithm, key size rule and decode option.
  • The AuthType reference shows the other schemes (Basic, Digest, custom headers) and how to combine them.
  • The headers reference lists the typed headers, including Header.Authorization and Header.WWWAuthenticate.
  • To protect many routes, give authenticated to your router once per route group, or route with RoutePattern and wrap the matched handler.