ywt

ywt is a suite of Gleam-native JWT packages. This is the ywt_webcrypto package, providing cryptographic routines using the SubtleCrypto web api to ywt. It supports the server as well as the browser.

Package Version Hex Docs Package Version Hex Docs Package Version Hex Docs

gleam add ywt_core@1 ywt_webcrypto@1

ywt signs and verifies JWTs with symmetric and asymmetric algorithms on Erlang and JavaScript targets.

ywt calls the platform crypto APIs directly (public_key on Erlang and SubtleCrypto on JavaScript) through a small FFI layer. The FFI only performs key generation, signing, and signature verification, so the platform boundary is short enough to audit; parsing, signing input construction, key decoding, and claim validation stay in Gleam.

Supported Algorithms

Security

JWTs are signed, not encrypted.

Anything in a JWT can be read by whoever has the token, including after the token expires or is revoked. Do not put secrets, passwords, or sensitive personal data in the payload.

Always add an expiration claim with claim.expires_at. ywt checks exp, nbf, and aud when they are present even if you did not configure matching claims: expired tokens are rejected, not-yet-valid tokens are rejected, and tokens with an aud claim are rejected unless you explicitly accept that audience. Audience validation accepts string and array values.

JWTs have no built-in revocation. If you need logout, account disablement, or emergency key compromise handling, keep server-side state such as short token lifetimes, key rotation, a jti denylist, or session records.

ywt ignores unknown non-critical JWT header fields and unknown JWK fields while decoding. Tokens with a crit header parameter or b64: false are rejected. Your payload decoder controls which payload fields are accepted. ywt does not enforce JWK key_ops or use, validate certificate chains, or support encrypted or nested JWTs/JWKs.

General tips

Avoid JWTs unless you need their specific tradeoffs. Server-side sessions or opaque bearer tokens are usually easier to revoke, rotate, and reason about. JWTs commonly cause problems around revocation, long-lived leaked tokens, key rotation, audience confusion, and accidentally exposing data in readable payloads. They are most useful when multiple services need to verify tokens without a central lookup on every request. If you mainly need encrypted bearer tokens, consider amaro instead.

Keep signing keys out of source code and rotate them with kid values so verifiers can accept old and new keys during the transition. Prefer asymmetric keys when services only need to verify tokens; HMAC verification keys are shared secrets and can also sign tokens.

Only load verification keys from sources you already trust. When rejecting a token, return the same authentication failure to clients regardless of the specific error, and consider rate limiting repeated failures.

Alternatives

If you need encrypted bearer tokens rather than signed JWTs, consider amaro, a Gleam package for Fernet and Branca token encryption.

If you want a less opinionated library with a larger JOSE/JWT surface area, consider gose, which covers JOSE, JWK, JWT, and related COSE standards.

Example

import gleam/dynamic/decode
import gleam/io
import gleam/javascript/promise
import gleam/json
import gleam/string
import gleam/time/duration
import ywt
import ywt/algorithm
import ywt/claim
import ywt/verify_key

pub fn main() -> promise.Promise(_) {
  // Generate a new, random signing key
  use signing_key <- promise.await(ywt.generate_key(algorithm.es384))

  // Create user payload data
  let payload = [
    #("sub", json.string("user123")),
    #("role", json.string("admin")),
  ]

  // Define security claims
  let claims = [
    claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)),
    claim.issuer("https://auth.myapp.com", []),
    claim.audience("https://api.myapp.com", []),
  ]

  // Create and sign the JWT
  use jwt <- promise.await(ywt.encode(payload, claims, signing_key))
  io.println("Signed JWT: " <> jwt)

  // Extract verification key (for distribution to other services)
  let verify_key = verify_key.derived(signing_key)
  io.println(
    "Public verification key: " <> json.to_string(verify_key.to_jwk(verify_key)),
  )

  // Verify the JWT
  let decoder = {
    use id <- decode.field("sub", decode.string)
    use role <- decode.field("role", decode.string)
    decode.success(#(id, role))
  }

  use result <- promise.await(
    ywt.decode(jwt, using: decoder, claims:, keys: [verify_key]),
  )

  case result {
    Ok(#(id, role)) -> {
      io.println("JWT verified successfully!")
      io.println("User id: " <> id)
      io.println("User role: " <> role)
    }

    Error(ywt.TokenExpired(_expired_at)) -> {
      io.println("Token expired!")
    }

    Error(ywt.InvalidSignature) -> {
      io.println("Invalid signature - token may be forged")
    }

    Error(error) -> {
      io.println("JWT verification failed: " <> string.inspect(error))
    }
  }

  promise.resolve(Nil)
}

Development

Tests are shared by both targets and can be run from their individual package directories.

Resources:

Search Document