This guide shows you how to add LTI 1.3 launch support to a Phoenix application. By the end, your app will handle launches from Canvas, Moodle, Blackboard, or any other LTI 1.3 platform. You'll also have a clear list of what's left to get it production-ready.
You'll need an existing Phoenix app with Ecto. If you'd like to understand the protocol before starting, read LTI Advantage Concepts first.
Installation
Add the dependency
# mix.exs
defp deps do
[
{:ltix, "~> 0.2"}
]
endRun mix deps.get to fetch the package.
Start the key set
Ltix verifies launches with each platform's public keys, which it gets from a key set. Add the key set to your supervision tree.
# lib/my_app/application.ex
children = [
# ... your existing children (Repo, Endpoint, etc.)
Ltix.JWT.KeySet
]Set up key encryption
Your tool signs with private keys that you'll store in the database. Encrypt them there, so a database dump or a read-only breach doesn't expose your keys.
Use the encryption your app already has, or a key management service. If you have neither, we recommend Cloak. Follow its install guide to set up a vault and a binary field type. This guide calls that type MyApp.Encrypted.Binary.
Database schema
You need tables for four LTI concerns (signing keys, registrations,
deployments, and replay-prevention tokens) plus application tables for
users and courses. All the LTI schemas are under a MyApp.Lti context.
Migration
# priv/repo/migrations/XXXXXXXXXXXXXX_create_lti_tables.exs
defmodule MyApp.Repo.Migrations.CreateLtiTables do
use Ecto.Migration
def change do
create table(:lti_jwks) 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, null: false
timestamps()
end
create unique_index(:lti_jwks, [:kid])
create table(:lti_registrations) do
add :issuer, :string, null: false
add :client_id, :string, null: false
add :auth_endpoint, :string, null: false
add :jwks_uri, :string, null: false
add :token_endpoint, :string
add :jwk_id, references(:lti_jwks), null: false
timestamps()
end
create unique_index(:lti_registrations, [:issuer, :client_id])
create table(:lti_deployments) do
add :deployment_id, :string, null: false
add :registration_id, references(:lti_registrations), null: false
timestamps()
end
create unique_index(:lti_deployments, [:deployment_id, :registration_id])
create table(:lti_nonces, primary_key: false) do
add :nonce, :string, primary_key: true
add :expires_at, :utc_datetime, null: false
end
create table(:users) do
add :lti_sub, :string, null: false
add :name, :string
add :email, :string
add :registration_id, references(:lti_registrations), null: false
timestamps()
end
create unique_index(:users, [:lti_sub, :registration_id])
create table(:courses) do
add :context_id, :string, null: false
add :title, :string
add :registration_id, references(:lti_registrations), null: false
timestamps()
end
create unique_index(:courses, [:context_id, :registration_id])
end
endRun mix ecto.migrate.
Schemas
# lib/my_app/lti/jwk.ex
defmodule MyApp.Lti.Jwk do
use Ecto.Schema
schema "lti_jwks" 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
endThe private key is encrypted in the database and left out of inspect. If you encrypt with something other than Cloak, use its field type in place of MyApp.Encrypted.Binary. The public key is stored next to it, so serving your key set never reads a private key.
# lib/my_app/lti/registration.ex
defmodule MyApp.Lti.Registration do
use Ecto.Schema
schema "lti_registrations" do
field :issuer, :string
field :client_id, :string
field :auth_endpoint, :string
field :jwks_uri, :string
field :token_endpoint, :string
belongs_to :jwk, MyApp.Lti.Jwk
has_many :deployments, MyApp.Lti.Deployment
timestamps()
end
end
defimpl Ltix.Registerable, for: MyApp.Lti.Registration do
def to_registration(registration) do
Ltix.Registration.new(%{
issuer: registration.issuer,
client_id: registration.client_id,
auth_endpoint: registration.auth_endpoint,
jwks_uri: registration.jwks_uri,
token_endpoint: registration.token_endpoint
})
end
end# lib/my_app/lti/deployment.ex
defmodule MyApp.Lti.Deployment do
use Ecto.Schema
schema "lti_deployments" do
field :deployment_id, :string
belongs_to :registration, MyApp.Lti.Registration
timestamps()
end
end
defimpl Ltix.Deployable, for: MyApp.Lti.Deployment do
def to_deployment(deployment) do
Ltix.Deployment.new(deployment.deployment_id)
end
end# lib/my_app/lti/nonce.ex
defmodule MyApp.Lti.Nonce do
use Ecto.Schema
@primary_key {:nonce, :string, autogenerate: false}
schema "lti_nonces" do
field :expires_at, :utc_datetime
end
end# lib/my_app/user.ex
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :lti_sub, :string
field :name, :string
field :email, :string
belongs_to :registration, MyApp.Lti.Registration
timestamps()
end
def changeset(user, attrs) do
user
|> cast(attrs, [:lti_sub, :name, :email, :registration_id])
|> validate_required([:lti_sub, :registration_id])
|> unique_constraint([:lti_sub, :registration_id])
end
end# lib/my_app/course.ex
defmodule MyApp.Course do
use Ecto.Schema
import Ecto.Changeset
schema "courses" do
field :context_id, :string
field :title, :string
belongs_to :registration, MyApp.Lti.Registration
timestamps()
end
def changeset(course, attrs) do
course
|> cast(attrs, [:context_id, :title, :registration_id])
|> validate_required([:context_id, :registration_id])
|> unique_constraint([:context_id, :registration_id])
end
endContext module
The MyApp.Lti context module handles persistence for launch data and
key management.
# lib/my_app/lti.ex
defmodule MyApp.Lti do
alias MyApp.{Course, Repo, User}
alias MyApp.Lti.{Deployment, Jwk, Nonce, Registration}
import Ecto.Query
def create_user!(%Ltix.LaunchContext{claims: claims, registration: registration}) do
attrs = %{
lti_sub: claims.subject,
registration_id: registration.id,
name: claims.name,
email: claims.email
}
%User{}
|> User.changeset(attrs)
|> Repo.insert!(
on_conflict: {:replace, [:name, :email, :updated_at]},
conflict_target: [:lti_sub, :registration_id],
returning: true
)
end
def create_course!(%Ltix.LaunchContext{claims: claims, registration: registration}) do
attrs = %{
context_id: claims.context.id,
registration_id: registration.id,
title: claims.context.title
}
%Course{}
|> Course.changeset(attrs)
|> Repo.insert!(
on_conflict: {:replace, [:title, :updated_at]},
conflict_target: [:context_id, :registration_id],
returning: true
)
end
def create_jwk! do
jwk = Ltix.JWK.generate()
# Ecto's debug log prints query parameters, and the key is one of them.
Repo.insert!(
%Jwk{
kid: jwk.kid,
public_key_pem: jwk.public_key_pem,
private_key_pem: jwk.private_key_pem
},
log: false
)
end
def list_public_jwks do
keys = Repo.all(from j in Jwk, where: j.active == true, select: {j.kid, j.public_key_pem})
Enum.map(keys, fn {kid, pem} -> Ltix.JWK.new!(public_key_pem: pem, kid: kid) end)
end
# The platform left out client_id, so look up by issuer alone. An issuer
# with several registrations is ambiguous and needs the client_id.
def get_registration(issuer, nil) do
case Repo.all(from r in Registration, where: r.issuer == ^issuer) do
[registration] -> {:ok, registration}
_none_or_several -> {:error, :not_found}
end
end
def get_registration(issuer, client_id) do
case Repo.get_by(Registration, issuer: issuer, client_id: client_id) do
nil -> {:error, :not_found}
registration -> {:ok, registration}
end
end
def get_deployment(%Registration{id: registration_id}, deployment_id) do
case Repo.get_by(Deployment, registration_id: registration_id, deployment_id: deployment_id) do
nil -> {:error, :not_found}
deployment -> {:ok, deployment}
end
end
def validate_nonce(nonce, expires_at) do
case Repo.insert_all(Nonce, [%{nonce: nonce, expires_at: expires_at}], on_conflict: :nothing) do
{1, _} -> :ok
{0, _} -> {:error, :nonce_already_used}
end
end
def signing_key_id(%Registration{jwk_id: jwk_id}) do
{:ok, Repo.get!(Jwk, jwk_id).kid}
end
def sign(key_id, message) do
case Repo.get_by(Jwk, kid: key_id, active: true) do
nil -> {:error, :unknown_key}
jwk -> Ltix.JWK.sign(Ltix.JWK.new!(private_key_pem: jwk.private_key_pem, kid: jwk.kid), message)
end
end
endMultitenancy
Where you draw tenant boundaries depends on your application. Two approaches are common.
Tenant per registration - each platform +
client_idpair is a tenant. This works well for cloud-hosted platforms like Canvas Cloud or Schoology, where many institutions share a common issuer URL but each institution gets its ownclient_id. The registration is your natural tenant boundary.Tenant per deployment - a single registration can have multiple deployments (e.g., a tool installed across departments). If you need isolation within a single institution, use the deployment as your tenant boundary instead.
The examples in this guide tenant on registration for simplicity.
Storage adapter
Ltix needs your application to look up registrations and deployments, to record used nonces (one-time values that prevent replayed launches), and to sign what your tool sends to platforms. You provide these through the Ltix.StorageAdapter behaviour.
# lib/my_app/lti/storage.ex
defmodule MyApp.Lti.Storage do
@behaviour Ltix.StorageAdapter
alias MyApp.Lti
@impl true
def get_registration(issuer, client_id, _opts), do: Lti.get_registration(issuer, client_id)
@impl true
def get_deployment(registration, deployment_id, _opts), do: Lti.get_deployment(registration, deployment_id)
@impl true
def validate_nonce(nonce, _registration, expires_at, _opts), do: Lti.validate_nonce(nonce, expires_at)
@impl true
def key_id(registration, _opts), do: Lti.signing_key_id(registration)
@impl true
def sign(_registration, key_id, message, _opts), do: Lti.sign(key_id, message)
endThe storage adapter is a thin wrapper that delegates to your context module, where the actual queries are.
key_id/2 and sign/4 are optional. Ltix calls them to sign requests for Advantage services and deep linking responses, so a tool that only handles launches can leave them out.
Nil client_id
Older versions of some platforms may omit client_id from the login
request, in which case get_registration/3 receives nil as the
second argument. The issuer-only clause above handles that. See
Storage Adapters for details.
For more on nonce expiry, custom structs, and per-call adapter overrides, see Storage Adapters.
Configuration
Set which storage adapter Ltix uses.
# config/config.exs
config :ltix,
storage_adapter: MyApp.Lti.StorageTo override settings for a single call, pass options to the call. Ltix.handle_login/3 takes :storage_adapter and :storage_adapter_options. Ltix.handle_callback/3 takes :storage_adapter, :storage_adapter_options, :allow_anonymous, :max_token_age, :local_network_access, :req_options, :key_set, :claim_parsers, :role_parsers, and :clock_skew. See Ltix.handle_login/3 and Ltix.handle_callback/3 for what each option does.
Registering a platform
Before your tool can accept launches, you need to exchange configuration with the platform. The 1EdTech specs leave this as an out-of-band process. The platform gives you its URLs and a client ID, and you give it your tool's launch URL and public key endpoint. LTI Dynamic Registration automates this exchange, but Ltix does not currently support it.
How you handle new registrations in your app is entirely up to you. Some tools render a registration page when a launch arrives from an unrecognized issuer/client_id pair. Others have an admin UI. For this guide, we'll seed the data through IEx.
Generate a signing key
Your tool needs at least one RSA signing key to get started. Open IEx and generate one.
$ iex -S mix
iex> alias MyApp.{Lti, Repo}
iex> jwk = Lti.create_jwk!()
For key rotation in production, see JWK Management.
Add a registration
Each platform you connect to needs a registration and at least one
deployment. Construct an Ltix.Registration before persisting to
validate that all the fields are correct and the launch will work.
iex> attrs = %{
...> issuer: "https://canvas.example.edu",
...> client_id: "10000000000042",
...> auth_endpoint: "https://canvas.example.edu/api/lti/authorize_redirect",
...> jwks_uri: "https://canvas.example.edu/api/lti/security/jwks",
...> token_endpoint: "https://canvas.example.edu/login/oauth2/token"
...> }
iex> {:ok, _} = Ltix.Registration.new(attrs)Once it passes validation, persist it.
iex> registration = Repo.insert!(%Lti.Registration{
...> issuer: "https://canvas.example.edu",
...> client_id: "10000000000042",
...> auth_endpoint: "https://canvas.example.edu/api/lti/authorize_redirect",
...> jwks_uri: "https://canvas.example.edu/api/lti/security/jwks",
...> token_endpoint: "https://canvas.example.edu/login/oauth2/token",
...> jwk_id: jwk.id
...> })
iex> Repo.insert!(%Lti.Deployment{
...> deployment_id: "1",
...> registration_id: registration.id
...> })The exact URLs and IDs depend on your platform. Check your platform's LTI developer documentation for where to find them.
Phoenix setup
Routes and pipeline
LTI launches differ from normal browser requests in two ways.
- Platforms POST directly to your tool, so CSRF protection must be disabled on LTI routes.
- Launches typically happen inside an iframe on the platform, so
session cookies need
SameSite=None,Secure, andPartitionedto work in the third-party context.
Iframe strategy
Most LTI tools render inside the platform's iframe. If that's your
plan, configure SameSite=None and Partitioned on your session
cookie in the endpoint so it applies to all routes.
# lib/my_app_web/endpoint.ex
# Change your existing Plug.Session to:
plug Plug.Session,
store: :cookie,
key: "_my_app_key",
signing_salt: "your-salt",
same_site: "None",
secure: true,
extra: "Partitioned"This is the simplest approach. Your entire app works in the iframe with a single session cookie.
Partitioned gives the cookie a separate jar for each platform site that embeds your tool. Browsers that block third-party cookies still accept a partitioned one, so the session works inside the iframe in current versions of Chrome, Firefox, and Safari.
Iframe sessions
Without same_site: "None", the browser silently drops the session
cookie inside the iframe and your state check fails. This is the
most common source of "launch just silently fails" issues.
SameSite=None requires secure: true, which means HTTPS. You'll
need HTTPS in development too (e.g., via mix phx.gen.cert).
A partitioned cookie is only sent inside that platform's iframe. If your tool opens itself in a new tab after the launch, the tab starts without the session.
Three other approaches exist if rendering everything in an iframe doesn't fit your app.
New tab launches - set
targetto_blankin the platform's tool configuration. The launch opens a new browser tab, where browsers that block third-party cookies still accept yours. The platform still sends the launch as a cross-site POST, and browsers leaveSameSite=Laxcookies off those, so the cookie that holds the login state still needsSameSite=None. This works well for apps with complex navigation that doesn't fit an iframe.Split rendering - serve a lightweight iframe view for the launch, then link out to the full app in a new tab for deeper interaction. This lets you keep
SameSite=Laxon most routes while only the LTI endpoints useSameSite=None.Platform storage - keep the login state in the platform's window, for browsers that block your cookie during the launch. See Keeping Login State in Platform Storage.
The examples below assume the iframe approach.
Routes
# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :lti_launch do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
# Override the default CSP to allow embedding. Adjust this if you want
# to restrict which platforms can embed your tool.
plug :put_secure_browser_headers, %{
"content-security-policy" => "base-uri 'self'; frame-ancestors 'self' https:;"
}
# plug :protect_from_forgery <--- Omit this plug during the launch flow
# since platforms POST directly to your tool.
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
end
pipeline :lti do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :put_secure_browser_headers, %{
"content-security-policy" => "base-uri 'self'; frame-ancestors 'self' https:;"
}
plug :protect_from_forgery
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
end
scope "/lti", MyAppWeb do
pipe_through :lti_launch
get "/login", LtiController, :login
post "/login", LtiController, :login
post "/launch", LtiController, :launch
end
# Platforms fetch your key set as JSON, so it needs no browser pipeline.
scope "/lti", MyAppWeb do
get "/jwks", LtiController, :jwks
end
scope "/lti", MyAppWeb do
# Post-launch routes that require CSRF protection can go here with a different pipeline
pipe_through :lti
live "/dashboard", DashboardLive
end
# ... your other routes
endNote two things about these pipelines.
protect_from_forgeryis omitted in the:lti_launchpipeline because platforms POST directly to your tool. The LTI specification provides for CSRF protection through thestateandnonceparameters, which Ltix validates for you. Past the launch, we recommend enabling Phoenix's CSRF protection. This is done in the:ltipipeline in the example above, but you can organize your routes and pipelines however it makes sense for your app.put_secure_browser_headersoverrides the default CSP to setframe-ancestors 'self' https:, allowing any HTTPS origin to embed the tool. Phoenix defaults toframe-ancestors 'self', which blocks iframe embedding. If your tool has each platform's origin, you could restrictframe-ancestorsper tenant after launch, but the login endpoint (and registration endpoint, if you implement dynamic registration) must remain open. Whether the extra complexity is worth it depends on your threat model.
Controller
# lib/my_app_web/controllers/lti_controller.ex
defmodule MyAppWeb.LtiController do
use MyAppWeb, :controller
alias MyApp.Lti
require Logger
def login(conn, params) do
launch_url = url(conn, ~p"/lti/launch")
case Ltix.handle_login(params, launch_url) do
{:ok, %{redirect_uri: redirect_uri, state: state}} ->
conn
|> put_session(:lti_state, state)
|> redirect(external: redirect_uri)
{:error, error} ->
launch_failed(conn, error)
end
end
def launch(conn, params) do
state = get_session(conn, :lti_state)
case Ltix.handle_callback(params, state) do
{:ok, context} ->
user = Lti.create_user!(context)
course = Lti.create_course!(context)
conn
|> delete_session(:lti_state)
|> put_session(:user_id, user.id)
|> put_session(:course_id, course.id)
|> render(:launch, context: context, user: user, course: course)
{:error, error} ->
launch_failed(conn, error)
end
end
def jwks(conn, _params) do
json(conn, Ltix.JWK.to_jwks(Lti.list_public_jwks()))
end
# Error messages can include URLs and claim values, so they go to the
# log, and the user sees a general message.
defp launch_failed(conn, error) do
Logger.warning("LTI launch failed: #{Exception.message(error)}")
conn
|> put_status(Ltix.Errors.status_code(error))
|> render(:error, message: "The launch could not be completed.")
end
endThe view module and templates follow.
# lib/my_app_web/controllers/lti_html.ex
defmodule MyAppWeb.LtiHTML do
use MyAppWeb, :html
embed_templates "lti_html/*"
end<%!-- lib/my_app_web/controllers/lti_html/launch.html.heex --%>
<h1>Welcome, {@user.name}</h1>
<p>Course: {@course.title}</p>
<ul>
<li :for={role <- @context.claims.roles}>{role.name}</li>
</ul><%!-- lib/my_app_web/controllers/lti_html/error.html.heex --%>
<h1>Launch failed</h1>
<p>{@message}</p>The create-on-launch pattern shown here is common. It extracts the user and course from the launch data, persists them, and sets up a session.
This particular setup aligns with the platform-centric approach described in the Best Practices for LTI Assessment Tools. If user accounts in your tool are separate from platform accounts (such as in a standalone SaaS product), you might instead use a join table to link platform users to your internal accounts. Use whatever account binding strategy works best for your app.
For structured error matching (e.g., showing different messages for security errors vs. invalid configuration), see Error Handling.
Public key endpoint
The jwks action in the controller above serves your tool's public
keys as a JSON Web Key Set (JWKS). When you register your tool with a
platform, give it this URL (e.g., https://yourtool.com/lti/jwks).
The platform fetches your public keys from here to verify signed
requests from your tool during Advantage service calls (grades, roster,
etc.).
The endpoint serves the public keys stored with each signing key, so it never reads a private key. Include multiple keys during rotation so platforms can verify with either the current or previous key. See JWK Management for rotation strategies.
Next steps
You now have a working LTI launch. Here's what to do next before going to production.
Nonce cleanup - the nonces table grows with every launch. Add a periodic job (e.g., Oban) that deletes nonces past their
expires_at. See Storage Adapters for an example.Key rotation - signing keys should be rotated periodically. Serve both the old and new key from your public key endpoint during the transition. See JWK Management.
Hardening - CSRF protection after the launch, authorization from roles, redirect checks, and key handling. See Hardening Your Tool.
Error handling - the controller above shows one general message. Match on error classes to show users more helpful messages and choose log levels. See Error Handling.
Role-based authorization - decide what instructors, learners, and TAs can do in your tool. See Working with Roles.
Grades and roster - send scores back to the LMS gradebook and fetch class lists. See Advantage Services.
Tests - Ltix provides test helpers that simulate the full launch flow without calling a real platform. See Testing LTI Launches.