In this article
September 30, 2026
September 30, 2026

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.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

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.payload.signature
  

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:

  
{
  "alg": "RS256",
  "typ": "JWT"
}
  

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:

  
{
  "sub": "550e8400-e29b-41d4-a716-446655440000",
  "email": "hgranger@hogwarts.example",
  "roles": ["admin", "editor"],
  "iat": 1716239022,
  "iss": "your-saas-app",
  "aud": "your-api",
  "exp": 1716242622
}
  

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.

Algorithm Description Use case
HS256 HMAC with shared secret Simple systems, internal services
RS256 RSA with public/private keys Authorization servers, external IdPs
ES256 ECDSA with elliptic-curve keys Modern IdPs, compact signatures

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 (jose on Hex, the erlang-jose library 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 :crypto and :public_key modules and provides functions like JOSE.JWT.sign/3, JOSE.JWT.verify_strict/3, and JOSE.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 (joken on Hex) is the Elixir-friendly wrapper around JOSE. It provides a cleaner API with Joken.generate_and_sign/3, Joken.verify_and_validate/3, a Joken.Config behaviour for defining token modules, a hook system for extending verification (which is how JWKS works), and built-in claim validation for exp, iat, nbf, iss, and aud.‍
  • JokenJwks (joken_jwks on 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:

  
defp deps do
  [
    {:joken, "~> 2.6"},
    {:joken_jwks, "~> 1.7"},  # only if you need JWKS
    {:jason, "~> 1.4"},       # JSON library (Joken uses it by default)
  ]
end
  

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:

  
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -pubout -in private_key.pem -out public_key.pem
  

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:

  
https://api.workos.com/sso/jwks/your-client_id
  

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:

  
# Generate an RSA key pair
jwk = JOSE.JWK.generate_key({:rsa, 2048})

# Add a key ID
jwk = JOSE.JWK.merge(jwk, %{"kid" => UUID.uuid4(), "use" => "sig", "alg" => "RS256"})

# Export the public key as a JWK map (safe to expose)
{_meta, public_map} = JOSE.JWK.to_public_map(jwk)

# Export the private key as a PEM (keep secret)
{_type, private_pem} = JOSE.JWK.to_pem(jwk)
  

Creating a JWT

Here is how to create and sign a token using Joken:

  
# Create a signer from your private key PEM
signer = Joken.Signer.create("RS256", %{"pem" => File.read!("private_key.pem")})

# Define the claims
claims = %{
  "sub" => "user_123",
  "email" => "hgranger@hogwarts.example",
  "roles" => ["admin", "editor"],
  "iss" => "https://your-app.example.com",
  "aud" => "your-api"
}

# Generate and sign the token
# The first argument is a token config (claim validations), second is extra claims
{:ok, token, _claims} = Joken.generate_and_sign(%{}, claims, signer)
  

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:

  
now = DateTime.utc_now() |> DateTime.to_unix()

claims = %{
  "sub" => "user_123",
  "iss" => "https://your-app.example.com",
  "aud" => "your-api",
  "iat" => now,
  "exp" => now + 15 * 60,  # 15 minutes
  "nbf" => now
}
  

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:

  
defmodule MyApp.Token do
  use Joken.Config

  @impl true
  def token_config do
    default_claims(
      iss: "https://your-app.example.com",
      aud: "your-api",
      default_exp: 15 * 60  # 15 minutes in seconds
    )
    |> add_claim("email", nil, &is_binary/1)
    |> add_claim("roles", nil, &is_list/1)
  end
end
  

With this module in place, signing and verifying becomes cleaner:

  
signer = Joken.Signer.create("RS256", %{"pem" => File.read!("private_key.pem")})

extra_claims = %{"sub" => "user_123", "email" => "hgranger@hogwarts.example", "roles" => ["admin"]}

{:ok, token, _claims} = MyApp.Token.generate_and_sign(extra_claims, signer)
  

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):

  
# Using JOSE directly (peek at header and payload)
protected = JOSE.JWT.peek_protected(token)
payload = JOSE.JWT.peek_payload(token)

IO.inspect(protected.fields)  # %{"alg" => "RS256", "kid" => "...", "typ" => "JWT"}
IO.inspect(payload.fields)    # %{"sub" => "user_123", "email" => "...", ...}
  

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

  
# Create a signer from the public key
signer = Joken.Signer.create("RS256", %{"pem" => File.read!("public_key.pem")})

case MyApp.Token.verify_and_validate(token, signer) do
  {:ok, claims} ->
    IO.puts("Subject: #{claims["sub"]}")
    IO.puts("Email: #{claims["email"]}")

  {:error, reason} ->
    IO.puts("Verification failed: #{inspect(reason)}")
end
  

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} when exp is 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:

  
public_jwk = JOSE.JWK.from_pem_file("public_key.pem")

case JOSE.JWT.verify_strict(public_jwk, ["RS256"], token) do
  {true, %JOSE.JWT{fields: claims}, _jws} ->
    # Signature valid, now validate claims manually
    if claims["exp"] > DateTime.utc_now() |> DateTime.to_unix() do
      {:ok, claims}
    else
      {:error, :token_expired}
    end

  {false, _, _} ->
    {:error, :invalid_signature}
end
  

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

  
defmodule MyApp.JwksStrategy do
  use JokenJwks.DefaultStrategyTemplate

  def init_opts(opts) do
    url = Application.get_env(:my_app, :jwks_url)
    Keyword.merge(opts, jwks_url: url)
  end
end
  

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

  
# lib/my_app/application.ex
defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      # ... other children
      {MyApp.JwksStrategy, time_interval: 60_000, first_fetch_sync: true},
      MyAppWeb.Endpoint,
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end
  

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

  
# config/runtime.exs
config :my_app, :jwks_url, System.get_env("JWKS_URL") || "https://api.workos.com/sso/jwks/your-client_id"

# config/config.exs
config :joken_jwks, :default_strategy, MyApp.JwksStrategy
  

Step 4: Define a token module with the JWKS hook

  
defmodule MyApp.Token do
  use Joken.Config

  add_hook(JokenJwks, strategy: MyApp.JwksStrategy)

  @impl true
  def token_config do
    default_claims(
      iss: "https://your-idp.example.com",
      aud: "your-api",
      skip: [:exp, :iat, :nbf, :jti]  # let the provider control these
    )
  end
end
  

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

  
case MyApp.Token.verify_and_validate(token) do
  {:ok, claims} ->
    # Token is verified and claims are validated
    {:ok, claims}

  {:error, reason} ->
    {:error, reason}
end
  

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:

  
defmodule MyAppWeb.Plugs.JwtAuth do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
         {:ok, claims} <- MyApp.Token.verify_and_validate(token) do
      conn
      |> assign(:current_user, claims)
      |> assign(:user_id, claims["sub"])
    else
      _ ->
        conn
        |> put_status(:unauthorized)
        |> Phoenix.Controller.json(%{error: "Missing or invalid token"})
        |> halt()
    end
  end
end
  

Wire it into your router:

  
defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  pipeline :api do
    plug :accepts, ["json"]
  end

  pipeline :authenticated do
    plug MyAppWeb.Plugs.JwtAuth
  end

  scope "/api", MyAppWeb do
    pipe_through [:api]
    get "/health", HealthController, :index
  end

  scope "/api", MyAppWeb do
    pipe_through [:api, :authenticated]
    get "/dashboard", DashboardController, :show
    get "/admin", AdminController, :index
  end
end
  

Access the authenticated user in controllers:

  
defmodule MyAppWeb.DashboardController do
  use MyAppWeb, :controller

  def show(conn, _params) do
    claims = conn.assigns.current_user
    json(conn, %{
      user_id: claims["sub"],
      email: claims["email"],
      roles: claims["roles"]
    })
  end
end
  

Role-based authorization plug

For endpoints that require specific roles:

  
defmodule MyAppWeb.Plugs.RequireRole do
  import Plug.Conn

  def init(role), do: role

  def call(conn, required_role) do
    roles = conn.assigns[:current_user]["roles"] || []

    if required_role in roles do
      conn
    else
      conn
      |> put_status(:forbidden)
      |> Phoenix.Controller.json(%{error: "Insufficient permissions"})
      |> halt()
    end
  end
end
  

Use it in a controller:

  
defmodule MyAppWeb.AdminController do
  use MyAppWeb, :controller
  plug MyAppWeb.Plugs.RequireRole, "admin"

  def index(conn, _params) do
    json(conn, %{message: "Welcome, admin"})
  end
end
  

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_strict with JOSE. If you call JOSE functions directly, always use JOSE.JWT.verify_strict/3 with an explicit list of allowed algorithms. The non-strict JOSE.JWT.verify/2 accepts 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 :joken or 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 DefaultStrategyTemplate is 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 iss and aud in your token config. Joken's default_claims/1 accepts iss and aud options and validates them automatically during verify_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 exp values (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. Use case or with for control flow, not try/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/2 with structured metadata when verification fails. Include the kid from 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.exs or the init_opts/1 callback in your JWKS strategy for values that differ between environments.‍
  • Test with bad tokens. Build test helpers that generate tokens with expired exp, wrong iss, wrong aud, wrong signing key, tampered payloads, and unknown kid values. 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.

Sign up for WorkOS today.