Introduction
In the introduction we briefly cover how to sign and verify JSON Web Tokens (JWTs).
- We start by declaring a custom
UserJwttype for representing the claims of user tokens. - Then we define
JwtEncoder[UserJwt]and sign an example token withJwtSigning[IO]. - Finally, we specify
JwtDecoder[UserJwt]and verify the token usingJwtVerification[IO].
Token Signing
We begin by defining a custom type UserJwt to represent the token.
final case class UserJwt(userId: String, expiresAt: Long, issuedAt: Long)
Then we define an Encoder.AsObject which encodes UserJwt as JsonObject.
import io.circe.Encoder
given Encoder.AsObject[UserJwt] =
Encoder.forProduct3("userId", "exp", "iat")(claims =>
(claims.userId, claims.expiresAt, claims.issuedAt)
)
Similarly, we define a JwtEncoder which encodes UserJwt as JwtBuilder.
import jots.JwtEncoder
given JwtEncoder[UserJwt] =
JwtEncoder.encodeClaims
Here, JwtEncoder.encodeClaims uses the Encoder.AsObject defined earlier to encode UserJwt as the claims of a JwtBuilder. The JwtBuilder type describes tokens prior to signing. Following, we create a UserJwt instance and use the JwtEncoder to generate a JwtBuilder instance.
import jots.syntax.*
val userJwt = UserJwt(
userId = "8d3bbd14-dfd9-47fa-aab4-d76daf00b4f1",
expiresAt = 3345062400L,
issuedAt = 1767225600L
)
// userJwt: UserJwt = UserJwt(8d3bbd14-dfd9-47fa-aab4-d76daf00b4f1,3345062400,1767225600)
userJwt.asJwt
// res0: JwtBuilder = JwtBuilder(JwtHeader(typ -> "JWT"),JwtClaims(userId -> "8d3bbd14-dfd9-47fa-aab4-d76daf00b4f1",exp -> 3345062400,iat -> 1767225600))
Finally, we need to sign the JwtBuilder to create a SignedJwt. For this we need a JwtSigning instance. In this case we chose to use HS256 (HMAC with SHA-256) which requires a secret key. The JwtSigning instance is only created and used once here, but it is normally reused multiple times.
import cats.effect.SyncIO
import cats.syntax.all.*
import jots.JwtHmacAlgorithm.HS256
import jots.JwtSigning
import jots.crypto.SecretKey
val signedJwt =
for {
secretKey <- SecretKey("he2DDxdpmVMUG8UiVobZhfqnz1FNJZgP2Twpq").liftTo[SyncIO]
signing <- JwtSigning.default[SyncIO].hmac(HS256, secretKey)
signedJwt <- userJwt.asJwt.signWith(signing)
} yield signedJwt
// signedJwt: SyncIO[SignedJwt] = SyncIO(...)
val signedJwtString =
signedJwt.map(_.show).unsafeRunSync()
// signedJwtString: String = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiI4ZDNiYmQxNC1kZmQ5LTQ3ZmEtYWFiNC1kNzZkYWYwMGI0ZjEiLCJleHAiOjMzNDUwNjI0MDAsImlhdCI6MTc2NzIyNTYwMH0.Lm53jxYMsKBH7qXG_8uh7wxgVrywkHKg8he8T9pwmQo
Note we use SyncIO and unsafeRunSync() here to show the final result. In practice, you would most likely use IO without unsafeRunSync(). We should take care to not put secrets, like SecretKey, in source code.
Note that, since we've signed this token, we are confident the token can be verified. In general though, SignedJwt represents unverified signed tokens, and should not be trusted before verification, which is what the next section covers. For more details on token signing, see the page on signing.
Verifying Tokens
We begin by defining a Decoder which decodes UserJwt from Json.
import io.circe.Decoder
given Decoder[UserJwt] =
Decoder.forProduct3("userId", "exp", "iat")(UserJwt.apply)
Similarly, we define a JwtDecoder which decodes UserJwt from VerifiedJwt.
import jots.JwtDecoder
given JwtDecoder[UserJwt] =
JwtDecoder.decodeClaims
Here, JwtDecoder.decodeClaims uses the Decoder above to decode UserJwt from the claims of a VerifiedJwt. The VerifiedJwt is a SignedJwt for which the signature has been verified. Verification typically includes verifying header and claims in addition to the signature.
Following, we use the default JwtVerification and decodeAs to parse, verify and decode a signed token. The default verifications include extra checks in addition to verifying the signature. See the page on verification for more details on the default checks, how they can be changed, and how to enable more checks.
import jots.JwtVerification
val userJwtDecoded =
for {
secretKey <- SecretKey("he2DDxdpmVMUG8UiVobZhfqnz1FNJZgP2Twpq").liftTo[SyncIO]
verification <- JwtVerification.default[SyncIO].hmac(HS256, secretKey)
userJwt <- verification.decodeAs[UserJwt](signedJwtString)
} yield userJwt
// userJwtDecoded: SyncIO[UserJwt] = SyncIO(...)
userJwtDecoded.unsafeRunSync() == userJwt
// res1: Boolean = true
Note we use SyncIO and unsafeRunSync() here to show the final result. In practice, you would most likely use IO without unsafeRunSync(). We should take care to not put secrets, like SecretKey, in source code.
For more details on token verification, see the page on verification.