This recipe sets up database-backed JWK storage with a dedicated table, a JWKS controller, and a key rotation function. If you haven't read the JWK Management guide yet, start there for background on key pairs, JWKS endpoints, and rotation.
Migration
A dedicated table keeps key management separate from registration logic and makes rotation straightforward:
create table(:lti_jwks) do
add :private_jwk, :binary, null: false
add :active, :boolean, default: true
timestamps()
end
alter table(:platform_registrations) do
add :jwk_id, references(:lti_jwks), null: false
endEncrypt private keys at rest
The private_jwk column holds secret key material. Use
Cloak or your database's native
encryption to encrypt it at rest.
Creating registrations with keys
Generate a key pair when creating a registration, and store only the private key:
defmodule MyApp.Lti do
def create_registration(attrs) do
{private, _public} = Ltix.JWK.generate_key_pair()
jwk =
Repo.insert!(%LtiJwk{
private_jwk: serialize(private)
})
%PlatformRegistration{}
|> PlatformRegistration.changeset(Map.put(attrs, :jwk_id, jwk.id))
|> Repo.insert()
end
defp serialize(jwk) do
jwk |> JOSE.JWK.to_map() |> elem(1) |> Jason.encode!()
end
defp deserialize(json) do
json |> JSON.decode!() |> JOSE.JWK.from_map()
end
endYour storage adapter preloads the key and deserializes it:
defp to_registration(record) do
%Ltix.Registration{
issuer: record.issuer,
client_id: record.client_id,
auth_endpoint: record.auth_endpoint,
jwks_uri: record.jwks_uri,
token_endpoint: record.token_endpoint,
tool_jwk: deserialize(record.jwk.private_jwk)
}
endJWKS controller
Serve all active public keys. to_jwks/1 derives public halves from
private keys automatically:
defmodule MyAppWeb.JwksController do
use MyAppWeb, :controller
import Ecto.Query
alias MyApp.Repo
alias MyApp.Lti.LtiJwk
def index(conn, _params) do
keys =
from(j in LtiJwk, where: j.active == true)
|> Repo.all()
|> Enum.map(fn jwk ->
jwk.private_jwk |> JSON.decode!() |> JOSE.JWK.from_map()
end)
conn
|> put_resp_content_type("application/json")
|> json(Ltix.JWK.to_jwks(keys))
end
end# router.ex
get "/.well-known/jwks.json", JwksController, :indexCache the JWKS response
Platforms may fetch your JWKS endpoint frequently. Consider caching the response in ETS or with a cache header rather than querying the database on each request.
Key rotation
Create a new key row, point the registration at it, and leave the old key active so platforms can still verify during the overlap period:
def rotate_key(registration_id) do
{new_private, _public} = Ltix.JWK.generate_key_pair()
new_jwk =
Repo.insert!(%LtiJwk{
private_jwk: serialize(new_private)
})
Repo.get!(PlatformRegistration, registration_id)
|> PlatformRegistration.changeset(%{jwk_id: new_jwk.id})
|> Repo.update!()
endThe old key stays active: true, so the JWKS endpoint continues
serving it. After platforms have refreshed their cache (24-48 hours
is typical), mark it inactive:
Repo.update!(LtiJwk.changeset(old_jwk, %{active: false}))