~/posts/introducing_gaseto.md─□✕

Introducing: Gaseto

John Mikael Lindbakk // 24/10/2026 12:00

Many argue that JSON Web Tokens (JWT) are problematic, especially when it comes to user authentication.

There is, however, a different standard that fixes a lot of these problems: PASETO.

Gaseto is a library that implements PASETO for the Gleam ecosystem.

Why JWTs are problematic

What people point to as the core issue with JWTs is that the standard allows insecure algorithms, while also being so broad that it is easy to end up with something insecure unless you really know what you're doing.

For example, JWTs allow for RSA-PKCS1v1.5 encryption, which is widely considered inadequate for securing a token. Heck, according to its own standard, a JWT technically doesn't even need to be signed or encrypted.

Most JWTs are also only signed, not encrypted: the payload is just base64url encoded, so anyone holding the token can read what's inside, be it user IDs, emails, etc. JWT does have an encrypted variant (JWE), but it's a separate and more complex spec, with its own algorithms to get wrong (like the RSA-PKCS1v1.5 above).

Most of all, the problem with JWTs is that they make it easy for developers to make mistakes.

The issues summarised above are not just theoretical, either:

In short: JWTs are a gun that makes your foot an easy target.

I'm far from the first to say this. If you want to dig deeper, these go through the problems with JWTs in much more detail:

PASETO

As a response to JWTs we got PASETO, Platform-Agnostic SEcurity TOkens (specification).

PASETO is similar to JWTs in some respects, but it tightens the security in a way that makes it very hard to misuse.

First and foremost: the only algorithms you're allowed to use are considered secure, and you cannot opt out. You don't choose the specific algorithm yourself either. The choice is more or less made for you, and you can trust it to be secure.

PASETO doesn't allow you to decode a token without verifying it. Many JWT libraries allow exactly that, which has led to multiple issues where tokens have been accepted without actually being verified.

One of the key reasons PASETO is so hard to misuse is that it gives developers essentially 2 options:

  • Version

  • Public/local

PASETO: Public/local

The difference between local and public comes down to keys:

  • local uses one shared secret key, both to create tokens and to check them. The payload is encrypted, so nobody without the key can see what's inside.

  • public uses a key pair: the secret key signs tokens, and the public key verifies them. The payload is signed, but not encrypted, so anyone holding the token can read it. Unlike with JWTs, this is never an accident: public is right there in the token.

That has an important consequence: with local, anything that can check a token can also create one. With public, you can hand the public key to as many services as you like, and they can verify tokens without ever being able to forge them.

We want to use local when the same service both creates and checks the tokens, as it keeps the content of the token hidden and secure.

We want to use public when other services need to verify the tokens, for example a login service that issues tokens and a handful of APIs that accept them. Just don't put anything secret in the claims, because anyone with the token can read them.

PASETO Version

PASETO technically has 4 versions, but V1 and V2 are deprecated, so in practice you pick between V3 and V4.

The default for most people should be V4.

V3 is mostly for those who work in organisations which are limited to NIST-approved algorithms, which is mostly related to government or regulated industries.

What the version (combined with local/public) really decides is which algorithms will be used:

However, the developer doesn't need to know the above. They just need to know the answer to the following:

  • Does anything other than the service that creates the token need to verify it? (Yes = public, No = local)

  • Am I limited to NIST-approved algorithms? (Yes = V3, No = V4)

In either case, security is upheld.

PASETO token structure

A PASETO token is a string of up to four parts, separated by dots (spec):

version.purpose.payload.footer

Here's a real v4.public token (shortened), made by go-paseto for Gaseto's interop tests:

v4.public.eyJhdWQiOiJnYXNldG8iLCJleHAiOiIyMDk5LTAx…GjvEzjFH0QUC.eyJraWQiOiJnby1rZXktMSJ9
  • Version (v4): which version, and therefore which algorithms, the token uses.

  • Purpose (public): local or public.

  • Payload: the claims and the cryptography, base64url encoded. For a local token, it's encrypted. For a public token, it's the claims as plain JSON followed by the signature, so the payload above starts with {"aud":"gaseto","exp":"2099-01-01T00:00:00Z",....

  • Footer (optional): extra data that isn't encrypted, but is covered by the authentication tag or signature, so it can't be tampered with. The footer above is {"kid":"go-key-1"}. The typical use is a key ID (kid) that tells the reader which key to use, which makes it possible to rotate keys. The footer can be read before the token is checked, so don't trust anything else in it until the token has been verified.

On top of that, v3 and v4 support implicit assertions: data that is covered by the authentication tag or signature, but isn't stored in the token at all. Whoever checks the token has to provide the same data, or the token is rejected. That lets you tie a token to something both sides already know, like a tenant ID, without putting it in the token.

What PASETO doesn't solve

PASETO fixes the token format, but it isn't magic. Some problems are still yours:

  • Revoking tokens. A token is valid until it expires, and PASETO has no way of taking it back. If a token is stolen, it works for whoever has it until then. The spec is blunt about this: neither PASETO nor JWT is meant to replace state on the server. Keep tokens short-lived, and if you need to log someone out right away, you need something stored on the server, like a list of revoked token IDs (jti) or a sessions table. The remember_me demo shows one way of doing it.

  • Checking the right claims. PASETO guarantees that a token hasn't been tampered with, not that it was meant for you. When the AshAuthentication library for Elixir forgot to check the token's purpose claim in 2026, that wasn't really JWT's fault, and it could just as well have happened with PASETO. Gaseto's rules (issued_by, for_audience and so on) make these checks easy, but you still have to ask for them.

  • Keeping keys safe. If someone gets hold of your local key or secret key, they can create any token they like. Store keys like any other secret, and have a plan for rotating them. The key_rotation demo shows how.

Gaseto

Gaseto is a library that implements the PASETO standard.

I built it for a few reasons. I wanted to learn a token standard that wasn't JWT, and I wanted to go more in depth on security. I also saw a gap: on the BEAM, the most established library, paseto for Elixir, only supports the deprecated v1 and v2, and there was nothing for Gleam at all. (A newer Elixir library, pasexto, has since appeared, which I didn't know about when I started.)

Gaseto lets you:

  • Create and check PASETO v3 and v4 tokens, both encrypted (local) and signed (public)
  • Generate keys, or load them from hex or PEM
  • Set the standard claims and add your own
  • Require a specific issuer, audience, subject or token ID, with expiry checked automatically
  • Add token footers and implicit assertions, and read footers safely before checking the token

Gaseto also follows the spec's advice on secure defaults, so you have to opt out of safety rather than opt in:

  • New tokens expire after one hour, unless you choose something else

  • Tokens without an expiry are rejected, unless you call allow_no_expiration

  • There's no leeway for clock differences between servers, unless you add it with allow_clock_skew

  • Tokens longer than 8192 bytes are rejected before any cryptography happens

  • Keys know their version and purpose, so a v3 key can't be used for v4 tokens, and a local key can't be used for public tokens

Can I trust Gaseto?

This is a very fair question!

At the end of the day, you have to take my word for it, or read the source code. That said, there are some indications that Gaseto is trustworthy.

The first indication is that Gaseto doesn't implement any of its own cryptographic primitives. Instead, Gaseto relies heavily on Erlang/OTP's own crypto, gleam_crypto, kryptos and eblake2. Hopefully I can reduce this list of dependencies in the future.

The one place Gaseto does a bit of assembly itself is XChaCha20 (used by v4.local): OTP doesn't provide it, so Gaseto builds it on top of OTP's ChaCha20. The ChaCha20 rounds still come from OTP, and the result is covered by the test vectors below.

The next indication is that Gaseto is verified against PASETO's test vectors. PASETO comes with test vectors that implementors must test against: paseto-standard/test-vectors

Gaseto has automated tests against these:

To verify that Gaseto can interoperate with other PASETO implementations, every CI run also generates 100 tokens (25 of each kind) with go-paseto and reads them in Gaseto, and vice versa.

Gaseto stays strictly on spec (which can be verified in the code), and rejects:

Using Gaseto

First, add Gaseto to your project:

gleam add gaseto

Then generate a key. Do it once, outside your app, and store it like any other secret:

// A local key, for local tokens
gaseto.generate_local_key(gaseto.PasetoV4) |> gaseto.local_key_to_hex

// A key pair, for public tokens
let secret_key = gaseto.generate_secret_key(gaseto.PasetoV4)
gaseto.secret_key_to_hex(secret_key)
gaseto.secret_key_to_public_key(secret_key) |> gaseto.public_key_to_hex

The generate_keys demo prints new keys of every kind with gleam run, and the keys guide also shows how to make them with OpenSSL.

With a key in hand, creating and reading a token looks like this:

// Loading key: key_hex can be loaded from wherever.
// The key decides version and public/local
let assert Ok(key) = gaseto.local_key_from_hex(gaseto.PasetoV4, key_hex)

// Custom defined claims
let claims = gaseto.new_claims() |> gaseto.with_subject("user-42")

// Encrypting the claims with the key, getting the token back
let assert Ok(token) = gaseto.encrypt(claims, key)

// Decrypting (and verifying) the token
let assert Ok(opened) = gaseto.decrypt(token, key, gaseto.new_rules())

// Getting the subject of a token ("user-42")
gaseto.subject(opened)

To keep things simple, this example uses a local token. Public tokens work the same way, with sign and verify and a key pair instead. For more realistic examples, have a look at the demos:

  • login_and_api: a login service that signs public tokens, and an API that checks them with only the public key

  • remember_me: a "remember me" cookie with a local token that can be revoked when the user logs out

  • key_rotation: switching to a new signing key without breaking existing tokens, using the footer

Gaseto & HTTP

My assumption is that a lot of people will use Gaseto together with HTTP, so I created a helper library: gaseto_http.

gleam add gaseto_http

gaseto_http is basically a wrapper that makes it easier to use Gaseto with Gleam HTTP.

Example: logging in a user

This is just demo code, so don't copy it. It is just an example of what a "login service" could do:

pub fn log_in(
  user_identifier: String,

  // The key loaded into the application (see "Using Gaseto")
  secret_key: gaseto.SecretKey,
) -> Response(String) {
  // How long a token should be valid for
  let lifetime = duration.minutes(15)

  // The claims that should go into the token
  let claims =
    gaseto.new_claims()
    |> gaseto.with_issuer("login.example.com") // Who issued the token
    |> gaseto.with_audience("api.example.com") // Who the token is meant for
    |> gaseto.with_subject(user_identifier) // Who the token is about
    |> gaseto.expires_in(lifetime) // When the token expires

  // Signs the claims
  case gaseto.sign(claims, secret_key) {
    // Signing was successful, returning response
    Ok(token) -> gaseto_http.access_token_response(token, lifetime)
    Error(_) -> response.new(500) |> response.set_body("")
  }
}

The code above results in an OAuth 2.0 access token response, something like:

{"access_token":"v4.public.eyJ...","token_type":"Bearer","expires_in":900}

Example: Checking bearer tokens

This reads the token from the Authorization: Bearer header, and if it is missing or invalid, responds with a 401 and a WWW-Authenticate challenge for the given realm:

pub fn handle(
  request: Request(body),
  public_key: gaseto.PublicKey,
) -> Response(String) {
  let rules =
    gaseto.new_rules()
    // Only accept tokens issued by our service
    |> gaseto.issued_by("login.example.com")
    // Only accept certain audiences
    |> gaseto.for_audience("api.example.com")

  use _token <- gaseto_http.require_verified_bearer(
    request, // The request
    key: public_key, // The public key used to verify the token
    rules:, // The rules for this service
    realm: "api.example.com", // Who this service is
    error_body: "", // What we want the error body to contain.
  )

  // Auth OK

  response.new(200) |> response.set_body("Hello, user")
}

Example: Cookies

The cookie can be set like this:

response.new(200)
|> gaseto_http.set_token_cookie(
  named: "__Host-token",
  token:,
  max_age: duration.hours(1),
)

This sets the Set-Cookie header (with Secure, HttpOnly, SameSite=Lax and Path=/), which the browser then stores. The __Host- prefix in the name makes the browser refuse the cookie unless it is Secure, has Path=/ and no Domain, which set_token_cookie takes care of.

And you can verify it like this:

use _token <- gaseto_http.require_verified_cookie(
  request,
  named: "__Host-token",
  key: public_key,
  // Same as before, in case you have extra rules
  rules:,
  // What to do when the cookie is missing or invalid
  otherwise: fn(_error) { redirect_to_login() },
)

Bear in mind that gaseto_http is just a helper library, and to get more control you can still do everything with plain gaseto.

Gaseto now V1

Gaseto is out and usable!

Gaseto:

gaseto_http: