Requesting an access token for Advantage services requires a signed JWT assertion, which means your tool needs an RSA key pair. This guide covers generating keys, serving a JWKS endpoint, and rotating keys.
Leaked keys let attackers impersonate your tool
Your private key signs the OAuth assertions that prove your tool's identity to platforms. If an attacker obtains it, they can request access tokens and call platform APIs as your tool, accessing roster data, posting grades, or reading course content. Never commit key material to version control, log it, or expose it via API responses.
Generating a key
jwk = Ltix.JWK.generate()Store kid, public_key_pem, and private_key_pem in your database or
config, and encrypt the private key wherever you keep it. To reconstruct the
struct later, pass kid and one of the keys to Ltix.JWK.new/1 or
Ltix.JWK.new!/1. A missing, invalid, or unknown field returns an
Ltix.Errors.Invalid.InvalidField error, and several bad fields are
returned together in an Ltix.Errors.Invalid error.
Use a separate key per registration so you can rotate them independently.
Signing with a key
Ltix never stores your private key. When it needs a signature, it calls your storage adapter's Ltix.StorageAdapter.key_id/2 and Ltix.StorageAdapter.sign/4 callbacks. Ltix.JWK.sign/2 produces the signature for a key your application has.
@impl true
def key_id(_registration, _opts), do: {:ok, current_jwk().kid}
@impl true
def sign(_registration, key_id, message, _opts) do
case Enum.find(active_jwks(), &(&1.kid == key_id)) do
nil -> {:error, :unknown_key}
jwk -> Ltix.JWK.sign(jwk, message)
end
endsign/4 signs with the key that has the given ID and returns an error for an ID it doesn't have. A request that starts before a rotation still gets a signature from the key whose ID is in its JWT header.
A key in a key management service does not have to be exported. Call the service from sign/4 and return its RS256 signature, and build the JWK for your key set endpoint from the public key the service returns. See Storage Adapters.
Serving a JWKS endpoint
Platforms fetch your public keys to verify your tool's signed assertions. You provide the JWKS endpoint URL during tool registration.
Each key has a unique kid, so platforms match signatures to the
correct key automatically. Serve the endpoint with to_jwks/1, which
returns a JWKS map with only the public keys.
def jwks(conn, _params) do
json(conn, Ltix.JWK.to_jwks(MyApp.Lti.list_public_jwks()))
endA JWK built from a public key is enough for this. Store each key's
public_key_pem and build the JWKs for the endpoint from that.
Ltix.JWK.new!(public_key_pem: key.public_key_pem, kid: key.kid)Ltix.JWK.new/1 also takes a public key as a DER binary or a JWK map,
which are the formats key management services return.
For tools with a single registration, you can hard-code a single key. For tools managing multiple registrations, see the Managing JWKs with Ecto cookbook for a database-backed approach with key rotation.
Key rotation
When you rotate a key, your JWKS endpoint must continue serving the old public key until platforms have refreshed their cache. A typical overlap period is 24-48 hours.
The rotation sequence has four steps.
- Generate a new key
- Start using the new key for signing
- Serve both old and new public keys from your JWKS endpoint
- After the overlap period, stop serving the old key
During the overlap, to_jwks/1 accepts a list, so serving both keys
is straightforward.
Ltix.JWK.to_jwks([old_jwk, new_jwk])Next steps
- Managing JWKs with Ecto - database storage, JWKS controller, and automated rotation
- Advantage Services - how keys are used in service authentication
- Storage Adapters - the callbacks that sign with your keys
Ltix.JWK- API reference