JWT

September 1, 2026 · View on GitHub

zio-blocks-jwt is a zero-dependency, cross-platform (JVM + Scala.js) JWT library for Scala 2.13 and Scala 3.

Getting Started

Add the dependency to your build.sbt:

libraryDependencies += "dev.zio" %% "zio-blocks-jwt" % "<version>"

Install a platform backend once at application startup:

import zio.blocks.jwt._
JvmJwtCryptoBackend.install()

For Scala.js (Node.js) use JsJwtCryptoBackend.install() instead (available on the JS platform with jwtJS).

Signing

import zio.blocks.jwt._
val key    = "0123456789ABCDEF0123456789ABCDEF".getBytes("UTF-8") // 32 bytes for HS256 (JWA minimum)
val claims = JwtClaims(sub = Some("user-123"), iss = Some("my-app"))
val token: Either[JwtError, String] = Jwt.sign(claims, key, Algorithm.HS256)

Decoding and Verifying

import zio.blocks.jwt._
val key    = "0123456789ABCDEF0123456789ABCDEF".getBytes("UTF-8")
val claims = JwtClaims(sub = Some("user-123"), iss = Some("my-app"))
val token  = Jwt.sign(claims, key, Algorithm.HS256).getOrElse("")
val result: Either[JwtError, JwtClaims] =
  Jwt.decode(token, key, Algorithm.HS256, clockSkewSeconds = 30L, issuer = Some("my-app"))

Claims

JwtClaims models the RFC 7519 registered claims plus arbitrary extra claims:

FieldTypeRFC claim
issOption[String]Issuer
subOption[String]Subject
audOption[JwtAudience]Audience
expOption[Long]Expiration (Unix seconds)
nbfOption[Long]Not Before (Unix seconds)
iatOption[Long]Issued At (Unix seconds)
jtiOption[String]JWT ID
extraMap[String, JwtValue]Custom claims, preserving JSON type

exp/nbf/iat are NumericDate per RFC 7519 §2: JSON numbers (integer, fractional, exponent) truncated toward zero to seconds, rejected if negative, non-finite, or out of Long range.

Extra claims

JwtValue is the public JSON ADT for extra (and for JwtClaims round-trip):

import zio.blocks.jwt._
import zio.blocks.chunk.Chunk
val claims = JwtClaims(
  sub   = Some("user-123"),
  extra = Map(
    "role"   -> JwtValue.Str("admin"),
    "level"  -> JwtValue.Num("3"),
    "active" -> JwtValue.Bool(true),
    "meta"   -> JwtValue.Null,
    "tags"   -> JwtValue.Arr(Chunk(JwtValue.Str("a"), JwtValue.Num("1"))),
    "profile"-> JwtValue.Obj(Map("age" -> JwtValue.Num("30"), "nested" -> JwtValue.Obj(Map("k" -> JwtValue.Str("v")))))
  )
)

Objects and arrays nest arbitrarily and are preserved exactly; no values are dropped during parsing.

Audience

Per RFC 7519 §4.1.3, aud may be a single string or an array. None and JwtValue.Null are treated as absent. Arrays must contain only strings; mixed or non-string elements are rejected with InvalidToken:

import zio.blocks.jwt._
import zio.blocks.chunk.Chunk
val c1 = JwtClaims(aud = Some(JwtAudience.Single("my-service")))
val c2 = JwtClaims(aud = Some(JwtAudience.Multiple(Chunk("service-a", "service-b"))))
val c3 = JwtClaims(aud = None) // absent

When expectedAudience is set in JwtDecodeOptions, aud is validated (must be present and contain the expected value; missing yields MissingClaim("aud"), mismatch yields InvalidToken); otherwise aud is only parsed.

Limits and Options

Resource limits are conservative and checked before allocation:

import zio.blocks.jwt._
val limits = JwtLimits(maxTokenChars = 8192, maxSegmentChars = 4096, maxJsonChars = 8192, maxDepth = 32, maxFields = 512, maxArrayElements = 512)
val decodeOpts = JwtDecodeOptions(
  issuer = Some("my-app"),
  expectedAudience = Some("my-service"),
  clockSkewSeconds = 30L,
  limits = limits,
  nowSeconds = Some(1700000000L)
)
val signOpts = JwtSignOptions(limits = limits)
val key = "0123456789ABCDEF0123456789ABCDEF".getBytes("UTF-8")
val claims = JwtClaims(sub = Some("user-123"))
val token = Jwt.sign(claims, key, Algorithm.HS256, signOpts).getOrElse("")
Jwt.sign(claims, key, Algorithm.HS256, signOpts)
Jwt.decode(token, key, Algorithm.HS256, decodeOpts)

JwtHeader also carries kid and typ (default "JWT"):

import zio.blocks.jwt._
val hdr = JwtHeader(Algorithm.HS256, typ = "JWT", kid = Some("key-1"))
val key = "0123456789ABCDEF0123456789ABCDEF".getBytes("UTF-8")
val claims = JwtClaims(sub = Some("user-123"))
Jwt.sign(claims, key, Algorithm.HS256, hdr)

Supported Algorithms and Key Strength

AlgorithmJVMJS (Node.js)Pure ScalaKey minima / curve
HS25632 bytes
HS38448 bytes
HS51264 bytes
RS256RSA ≥2048 bits
RS384RSA ≥2048 bits
RS512RSA ≥2048 bits
PS256RSA ≥2048 bits, PSS
PS384RSA ≥2048 bits, PSS
PS512RSA ≥2048 bits, PSS
ES256P-256 (prime256v1)
ES384P-384 (secp384r1)
ES512P-521 (secp521r1, 66-byte components)
EdDSAEd25519

Keys shorter than the HWA minima are rejected with InvalidKey (not UnsupportedAlgorithm or InvalidToken). RSA <2048 bits and EC curve mismatches (e.g. ES256 with secp384r1) are also rejected with InvalidKey.

Query what a backend supports at runtime:

import zio.blocks.jwt._
JvmJwtCryptoBackend.supportedAlgorithms // Set[Algorithm]

UnsupportedAlgorithm is returned when the backend does not support the algorithm: PS*/EdDSA on JS (Node), any asymmetric alg when JsCryptoCapability reports unavailable (browser/ESM without Node crypto), or an unknown alg header (FOO).

Browser/ESM without Node crypto (detected via JsCryptoCapability.isAvailable) returns UnsupportedAlgorithm for RS*/ES*; HMAC via SharedJwtCryptoBackend still works.

Error Handling

All errors are represented as JwtError subtypes (no stack traces). Limits and validation map to distinct cases:

ErrorMeaning
InvalidTokenMalformed structure, bad claim type, aud mixed, iss/sub not string, exp not number, etc.
ExpiredTokenexp is in the past (with clockSkewSeconds and nowSeconds)
NotYetValidnbf is in the future
InvalidSignatureSignature did not verify (after alg check, before claims parsing)
UnsupportedAlgorithmBackend does not support the algorithm or alg header is FOO
MissingClaimRequired claim absent (alg, or iss when issuer expected)
AlgorithmMismatchalg header differs from requested algorithm (header.alg != alg on sign or decode)
TokenTooLargeCompact token > maxTokenChars
SegmentTooLargeA segment > maxSegmentChars
JsonTooLargeDecoded JSON > maxJsonChars
TooDeepJSON depth > maxDepth
TooManyFieldsObject fields > maxFields
TooManyElementsArray elements > maxArrayElements

Base64URL Encoding

Base64Url is a public object (exposed for testing, but considered internal API) that encodes/decodes with no padding (=), per RFC 7515 §2 and RFC 4648 §5. Tokens containing = are rejected with InvalidToken; length mod 4 == 1 and non-Base64Url chars (+, /, !) are rejected; trailing bits for 2- or 3-char tails must be zero (e.g. AB / ABC are rejected).