How to handle JWT in Elixir
Everything you need to know to sign, verify, and validate JWTs in Elixir, from Joken and JOSE to JWKS caching with OTP, Phoenix plugs, and production best practices.
JWT handling in Elixir is different from most languages in a way that is worth stating up front: the caching, key rotation, and background refresh problems that other ecosystems solve with ad hoc timers or HTTP-level caches are naturally solved by OTP. A GenServer holds your JWKS signers in memory, a supervision tree restarts it if it crashes, and ETS gives you concurrent reads without bottlenecking on a single process. The building blocks are already there.
What is not already there is good documentation. The Elixir Forum is full of threads from developers struggling to verify tokens from Google, Auth0, or Firebase using Joken. The examples are sparse, the relationship between Joken and JOSE is confusing if you have not encountered it before, and the JWKS hook system requires understanding supervision trees before you can use it in production.
This guide covers all of it: how the library stack fits together, how to create and verify JWTs with Joken 2 and the underlying JOSE library, how to set up JWKS fetching as a supervised process, how to build a Phoenix authentication plug, and the practices that matter most in production Elixir applications.
!!Need to inspect a token? Use the WorkOS JWT Debugger to decode and inspect your JWTs directly in the browser. It's a quick way to verify your token's iss, aud, sub, and other claims while debugging.!!
JWT 101
A JSON Web Token is a compact, URL-safe token format used to securely transmit information between systems. At a high level, a JWT lets one system make a signed statement about a user or service, and lets another system verify that statement without needing to look anything up in a database.
They are typically used to indicate a user's identity and/or assert permissions and roles.
A JWT is composed of three parts, each Base64URL-encoded and separated by dots:
Header
The header contains metadata about the token, most importantly the signing algorithm used to create the signature (e.g., HMAC, RSA, or ECDSA). This tells the verifier how the token was signed and how it should be validated.
A typical header before encoding:
In this example, alg is set to RS256, representing RSA with SHA-256, and typ identifies this as a JWT.
Payload
The payload contains the actual data the token encodes. These data points are called claims.
Claims are pieces of information about the subject of the token and additional context about how it should be used. Some claims are registered and standardized, like iss, sub, aud, and exp (for the full list check the JWT claims registry). Others are custom and application-specific.
Example payload:
It is important to note that the payload is not encrypted. Anyone who has the token can decode it and read the claims. Do not put passwords, secrets, or high-risk PII in JWT payloads.
Signature
The signature ensures the token's integrity and confirms that it was issued by a trusted source. It is created by hashing the Base64URL-encoded header and payload with a secret key (for symmetric algorithms like HS256) or a private key (for asymmetric algorithms like RS256). The resulting hash is then Base64URL-encoded and appended to the token.
When a JWT is received, the verifier recomputes the signature using the appropriate key and compares it to the signature included in the token. If they do not match, the token has been tampered with and must be rejected.
JWTs are protected via JSON Web Signature (JWS). JWS is used to share data between parties when confidentiality is not required, because the claims within a JWS can be read by anyone (they are simply Base64URL-encoded). The signature provides authentication, not encryption. Some of the cryptographic algorithms JWS uses are HMAC, RSA, and ECDSA.
How the library stack fits together
The Elixir JWT ecosystem is a two-layer stack, and understanding the layers saves a lot of confusion.
- JOSE (
joseon Hex, theerlang-joselibrary by Andrew Bennett) is the low-level cryptographic foundation. It implements the full JOSE specification suite: JWS, JWE, JWK, and JWT. It works directly with Erlang's:cryptoand:public_keymodules and provides functions likeJOSE.JWT.sign/3,JOSE.JWT.verify_strict/3, andJOSE.JWK.from_pem/1. You can use it directly for complete control, but the API is verbose and returns raw tuples and structs that require manual handling. - Joken (
jokenon Hex) is the Elixir-friendly wrapper around JOSE. It provides a cleaner API withJoken.generate_and_sign/3,Joken.verify_and_validate/3, aJoken.Configbehaviour for defining token modules, a hook system for extending verification (which is how JWKS works), and built-in claim validation forexp,iat,nbf,iss, andaud. - JokenJwks (
joken_jwkson Hex) is a Joken hook that fetches and caches signing keys from a JWKS endpoint. It runs as a GenServer under your application's supervision tree, polls for key changes on a configurable interval, and uses ETS for concurrent reads. This is the piece that handles key rotation.
For most applications, you will use Joken for day-to-day token operations and add JokenJwks when you need to verify tokens from an external identity provider.
Dependencies
Add to your mix.exs:
Generating your keys
In this tutorial, we will be using RS256. This asymmetric algorithm requires two keys: a private key to sign the token and a public key to verify it. If you already have them, move along to the next section.
!!Asymmetric algorithms use a pair of public and private keys to sign and verify the tokens. They are more secure, scalable, and better for distributed systems but also more resource-intensive and complex. For more on the various algorithms see Which algorithm should you use to sign JWTs?!!
You can generate RSA keys with OpenSSL:
However, in production you should use JSON Web Key Sets (JWKS). If you are using a third-party identity provider (like WorkOS), they expose a JWKS endpoint automatically:
Key rotation, expiration, and distribution are handled by the provider. This is the recommended approach, and we will cover how to consume JWKS endpoints with JokenJwks later in this guide.
Generating keys in Elixir
You can generate an RSA key pair and convert it to a JWK using the JOSE library:
Creating a JWT
Here is how to create and sign a token using Joken:
The first argument to generate_and_sign is a token configuration map. Passing an empty map %{} means no claim generation or validation rules are applied at signing time. We will cover token configuration modules shortly.
Note that Joken automatically adds iat (issued at), exp (expiration), and nbf (not before) claims with sensible defaults when you use a Joken.Config module. When calling generate_and_sign directly with %{}, you need to add time-based claims yourself if you want them:
Using a Joken.Config module
For production code, define a token module. This is the idiomatic Joken pattern and gives you a single place to configure claim generation, validation rules, and the default signer:
With this module in place, signing and verifying becomes cleaner:
The default_claims/1 function automatically generates iat, exp, nbf, and jti claims with sensible values. The iss and aud values are validated during verification.
Decoding a JWT
To inspect a token without verifying it (for debugging only):
The peek_* functions are explicitly named to signal that the token has not been verified. Do not use these values for authorization decisions.
About the kid claim
The kid (key ID) appears in the JWT header. It tells your application which public key from a JWKS set should be used to verify the signature. This is how key rotation works: the identity provider publishes multiple keys at its JWKS endpoint and includes the kid in the JWT header to indicate which key was used for signing.
In the Joken/JokenJwks ecosystem, the DefaultStrategyTemplate GenServer caches the signers by kid and matches incoming tokens automatically. If a token arrives with an unknown kid, the strategy marks the cache as stale, and on the next poll cycle (every 60 seconds by default), it re-fetches the JWKS.
Verifying a JWT
Verifying with a local public key
verify_and_validate/2 does three things: verifies the signature using the signer, checks that exp has not passed and nbf is not in the future, and runs any custom validation functions defined in your token config.
Common error tuples you will encounter:
{:error, :token_expired}whenexpis in the past.{:error, :invalid_token}when the signature does not match.{:error, :signature_error}when the key type does not match the algorithm.{:error, [message: "Invalid token"]}as a general catch-all from JOSE.
Verifying with JOSE directly
If you want full control or need to enforce a specific algorithm (to prevent algorithm confusion attacks), use JOSE's verify_strict:
The second argument to verify_strict is the list of allowed algorithms. This is critical. If you use JOSE.JWT.verify/2 instead (without the _strict suffix), it accepts whatever algorithm the token header claims, which opens the door to algorithm confusion attacks. Always use verify_strict when verifying with JOSE directly.
Verifying with a JWKS endpoint
This is the recommended approach for production when your tokens come from an external identity provider. JokenJwks manages the full lifecycle: fetching keys from the JWKS URL, caching them in ETS, polling for changes, and matching the correct signer by kid.
Step 1: Define a strategy module
The init_opts/1 callback runs when the GenServer starts. This is the idiomatic place to read runtime configuration (useful for releases where compile-time config is not available).
Step 2: Add the strategy to your supervision tree
time_interval is how often (in milliseconds) the GenServer checks whether it needs to re-fetch keys. The default is 60 seconds. first_fetch_sync: true means the application will wait for the initial JWKS fetch to complete before starting. This is important because without it, requests that arrive before the first fetch will fail with no signers available. Set it to true unless your startup time constraints prevent it.
Step 3: Configure the JWKS URL
Step 4: Define a token module with the JWKS hook
Important: when using the JokenJwks hook, do not configure a default signer. The hook resolves the signer from the JWKS based on the token's kid header. If a default signer is present, it takes precedence and the hook is never called.
Step 5: Verify tokens
No signer argument is needed. The JokenJwks hook extracts the kid from the token header, looks up the matching signer from the GenServer's ETS cache, and passes it to Joken for verification. If the kid is not in the cache, the strategy marks it as a bad kid, and the next poll cycle triggers a re-fetch.
How the caching works under the hood
This is worth understanding because it is different from every other language in the series.
The DefaultStrategyTemplate runs as a GenServer. On startup, it fetches the JWKS from the configured URL, parses each key into a Joken.Signer, and stores them in an ETS table keyed by kid. A :check_fetch message fires on the configured time_interval.
When a token arrives with an unknown kid, the strategy writes a counter to the ETS table. On the next poll, the GenServer sees the counter is non-zero, re-fetches the JWKS, and resets the counter. This means there is at most one outbound HTTP request per poll interval, regardless of how many unknown kid values are seen. This is the built-in protection against kid-flooding attacks.
If the JWKS fetch fails (network error, 5xx from the provider), the GenServer keeps serving the last known good set of signers. This is the resilience that OTP gives you for free: the cache survives transient failures without intervention.
Building a Phoenix authentication plug
Phoenix uses plugs for request processing, and a JWT authentication plug fits naturally into a router pipeline:
Wire it into your router:
Access the authenticated user in controllers:
Role-based authorization plug
For endpoints that require specific roles:
Use it in a controller:
JWT best practices (Elixir edition)
JWTs are simple in structure, but security lives in the details you enforce. Here are the practices that matter most in production Elixir applications.
- Always use
verify_strictwith JOSE. If you call JOSE functions directly, always useJOSE.JWT.verify_strict/3with an explicit list of allowed algorithms. The non-strictJOSE.JWT.verify/2accepts whatever algorithm the token header claims, which enables algorithm confusion attacks. - Do not set a default signer when using JokenJwks. If a default signer is configured (either via
config :jokenor in your token module), it takes precedence over the JWKS hook. The hook is silently bypassed and your tokens are verified with the wrong key. This is the most common JokenJwks mistake. - Use
first_fetch_sync: true. Without it, your application starts accepting requests before the JWKS has been fetched, and every request fails until the first background fetch completes. The downside is a slightly longer startup, but the alternative is a window of broken authentication on every deploy. - Supervise your JWKS strategy. The
DefaultStrategyTemplateis a GenServer that must be in your supervision tree. If it is not supervised, it will not restart after a crash, and you will silently lose your key cache. Place it before your endpoint in the children list so keys are ready before requests arrive. - Validate
issandaudin your token config. Joken'sdefault_claims/1acceptsissandaudoptions and validates them automatically duringverify_and_validate. Always set these. A token that is cryptographically valid but intended for a different service should be rejected. - Enforce Bearer token format. Extract tokens only from the
Authorization: Bearer <jwt>header. Tokens in query parameters leak into logs, browser history, and referrer headers. - Keep access tokens short-lived. Short
expvalues (5 to 15 minutes) reduce the blast radius of a leaked token. If you need long sessions, use refresh tokens and rotate them. - Handle errors with pattern matching, not exceptions. Joken returns
{:ok, claims}and{:error, reason}tuples. This is idiomatic Elixir. Usecaseorwithfor control flow, nottry/rescue. The only exceptions in the JWT stack come from JOSE on truly unexpected conditions (malformed keys, missing crypto support). - Log failures with metadata. Use
Logger.warning/2with structured metadata when verification fails. Include thekidfrom the token header and the error reason. Never log the full token string. - Use runtime configuration for keys and URLs. In releases (the standard deployment model for Elixir since Mix releases and Distillery), compile-time config is baked into the release. Use
config/runtime.exsor theinit_opts/1callback in your JWKS strategy for values that differ between environments. - Test with bad tokens. Build test helpers that generate tokens with expired
exp, wrongiss, wrongaud, wrong signing key, tampered payloads, and unknownkidvalues. Joken makes this easy because you can create signers and tokens directly in test code.
Let WorkOS handle the heavy lifting
While handling JWTs with Joken is often necessary at the API layer, it is worth stepping back and looking at the bigger picture: how those tokens are issued in the first place.
If you are building authentication flows, especially ones that involve Single Sign-On (SSO), SCIM provisioning, or multi-tenant identity, there is a lot more to solve than signing and verifying tokens. You need to support different identity providers, manage users and directories, rotate keys safely, and issue tokens that downstream services can trust.
WorkOS provides a modern API for enterprise-ready authentication features, letting you integrate SSO (SAML, OIDC, and more), manage users and directories, and issue secure tokens without building and maintaining a full auth stack from scratch. WorkOS has an Elixir SDK that handles the OAuth flow, token exchange, and user management with idiomatic Elixir code. It is especially useful if you need to support enterprise customers or want to offer a "Login with your company" experience. And it is free for up to 1,000,000 monthly active users.