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. Each row has the key ID, the public key your key set endpoint serves, and the private key, encrypted.
create table(:lti_keys) do
add :kid, :string, null: false
add :public_key_pem, :text, null: false
add :private_key_pem, :binary, null: false
add :active, :boolean, default: true
timestamps()
end
create unique_index(:lti_keys, [:kid])
alter table(:platform_registrations) do
add :key_id, references(:lti_keys), null: false
endSchema
The private key is encrypted at rest and left out of inspect. MyApp.Encrypted.Binary here is a Cloak field type, set up in Getting Started. Any encrypted field type works in its place.
defmodule MyApp.Lti.LtiKey do
use Ecto.Schema
schema "lti_keys" do
field :kid, :string
field :public_key_pem, :string
field :private_key_pem, MyApp.Encrypted.Binary, redact: true
field :active, :boolean, default: true
timestamps()
end
endCreating registrations with keys
Generate a key when creating a registration.
defmodule MyApp.Lti do
def create_registration(attrs) do
key = create_key!()
%PlatformRegistration{}
|> PlatformRegistration.changeset(Map.put(attrs, :key_id, key.id))
|> Repo.insert()
end
def create_key! do
jwk = Ltix.JWK.generate()
# Ecto's debug log prints query parameters, and the key is one of them.
Repo.insert!(
%LtiKey{
kid: jwk.kid,
public_key_pem: jwk.public_key_pem,
private_key_pem: jwk.private_key_pem
},
log: false
)
end
endSigning
Your storage adapter signs with the registration's key through the key_id/2 and sign/4 callbacks.
@impl true
def key_id(%PlatformRegistration{key_id: key_id}, _opts) do
{:ok, Repo.get!(LtiKey, key_id).kid}
end
@impl true
def sign(_registration, key_id, message, _opts) do
case Repo.get_by(LtiKey, kid: key_id, active: true) do
nil -> {:error, :unknown_key}
key -> Ltix.JWK.sign(Ltix.JWK.new!(private_key_pem: key.private_key_pem, kid: key.kid), message)
end
endsign/4 looks the key up by the ID key_id/2 returned, so a request that starts before a rotation still signs with the key whose ID is in its JWT header.
JWKS controller
Serve the public key of every active key. The query selects only the public column, so the endpoint never reads a private key.
defmodule MyAppWeb.JwksController do
use MyAppWeb, :controller
import Ecto.Query
alias MyApp.Repo
alias MyApp.Lti.LtiKey
def index(conn, _params) do
keys = Repo.all(from k in LtiKey, where: k.active == true, select: {k.kid, k.public_key_pem})
jwks = Enum.map(keys, fn {kid, pem} -> Ltix.JWK.new!(public_key_pem: pem, kid: kid) end)
json(conn, Ltix.JWK.to_jwks(jwks))
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.
Key rotation
Create a new key row, set it as the registration's key, and leave the old key active so platforms can still verify during the overlap period.
def rotate_key(registration_id) do
new_key = create_key!()
Repo.get!(PlatformRegistration, registration_id)
|> PlatformRegistration.changeset(%{key_id: new_key.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!(LtiKey.changeset(old_key, %{active: false}))