How to Build an LTI 1.3 Tool and Connect It to Canvas LMS

Devendran 17/09/2025
LTI 1.3 Canvas LMS JWKS OAuth 2.0 Node.js

Step 1: Ensure Canvas LMS Is Running Locally

Before we begin building our LTI 1.3 tool, make sure you have a local Canvas LMS instance up and running. If you already have Canvas installed, you can skip the installation steps.

If you don’t have Canvas set up yet, follow the official instructions here:

Canvas LMS Repo:
πŸ‘‰ Canvas LMS on GitHub

Official Installation Guide (Non-Docker):
πŸ‘‰ Canvas LMS Quick Start (Non-Docker Setup)

In this walkthrough, we’ll stick with the non-Docker setup so everything runs directly on our system.

⚠️ Note: Canvas requires PostgreSQL and Redis to be installed and running on your machine.

To double-check, open a terminal and run:

psql --version # should print Postgres version pg_isready # checks PostgreSQL status redis-cli ping # should return PONG if Redis server is running

Step 2: Understanding LTI 1.3 and Why We Need an App

Now that we have Canvas LMS running locally, the next step is to create our own LTI 1.3 tool.

But why do we need an LTI app?
LTI (Learning Tools Interoperability) is the standard way for external apps (like quizzes, SCORM players, custom tools, etc.) to connect with an LMS such as Canvas. By creating an LTI 1.3 app, we can securely launch our tool inside Canvas and exchange information like user identity, course context, and grades.

In short:

  • Canvas (the platform) needs to know how to launch your app.
  • Your app (the tool) needs to accept that launch request and respond correctly.

That’s where the LTI 1.3 flow comes in β€” it uses OAuth 2.0 + JWT for secure communication.

For reference:
πŸ‘‰ IMS Global LTI 1.3 Specification
πŸ‘‰ Canvas LTI 1.3 Documentation

In the next steps, we’ll set up a simple Node.js app that implements the LTI 1.3 handshake and can be connected to our local Canvas instance.

Step 3: Building Our LTI 1.3 App (Node.js)

Now that we understand what an LTI 1.3 tool is, let's actually build one. We'll use Node.js + Express to create a minimal app that can:

  • Handle the LTI login initiation
  • Handle the launch request from Canvas
  • Generate and serve cryptographic keys for secure communication
  • Validate and display user/course info

Let's walk through the code step by step.

1. Basic Setup & Dependencies

import express from "express"; import crypto from "crypto"; import { v4 as uuidv4 } from "uuid"; import { importSPKI, exportJWK } from "jose"; import helmet from "helmet"; import cors from "cors"; import path from "path"; import fs from "fs"; import "dotenv/config"; const app = express(); const PORT = 8001; const TOOL_URL = `http://localhost:${PORT}`; const ISSUER = "http://localhost:3000"; // Canvas LMS running locally

Key Points:

  • TOOL_URL β†’ Your app's base URL. (In production, this should be public so Canvas can reach it.)
  • ISSUER β†’ Your Canvas LMS URL (default is http://localhost:3000). Update if yours runs on another port
  • jose library β†’ Used for handling JWT/JWK cryptographic operations

2. Security Setup (Headers, CORS & Storage)

// Security headers for LTI iframe launches app.use((req, res, next) => { res.setHeader( "Content-Security-Policy", "frame-ancestors 'self' https://*.instructure.com https://*.canvaslms.com http://localhost:* http://127.0.0.1:*;" ); res.removeHeader("X-Frame-Options"); next(); }); app.use( helmet({ contentSecurityPolicy: false, frameguard: false, }) ); // Allow requests from Canvas const corsOptions = { origin: ["http://localhost:3000", "https://canvas.instructure.com"], credentials: true, }; app.use(cors(corsOptions)); app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Temporary storage (use DB in production) const nonceStore = new Map(); let privateKey; let publicJwk;

Quick Notes:

frame-ancestors β†’ Controls which sites can embed your app in an iframe. Canvas launches your tool inside an iframe, and modern browsers block iframe content by default if headers like X-Frame-Options or CSP are restrictive. Setting frame-ancestors allows Canvas (and local testing URLs) to display your app correctly.

helmet β†’ By default, Helmet sets X-Frame-Options: DENY, which prevents iframes. We disable this so Canvas can load our LTI tool.

CORS β†’ Restricts which origins can make requests to your server. Here, we allow Canvas URLs (localhost:3000 for local testing) so the LTI launch can send POST requests.

nonceStore β†’ Holds temporary state + nonce values during the OAuth login flow. Ensures requests are valid and protects against replay attacks. (In production, use a database or Redis; in local dev, memory is fine.)

3. Cryptographic Key Management

async function initializeKeys() { if (process.env.PRIVATE_KEY && process.env.PUBLIC_JWK) { privateKey = process.env.PRIVATE_KEY.replace(/\\n/g, "\n"); publicJwk = JSON.parse(process.env.PUBLIC_JWK); } else { const { publicKey, privateKey: privKey } = crypto.generateKeyPairSync( "rsa", { modulusLength: 2048, publicKeyEncoding: { type: "spki", format: "pem" }, privateKeyEncoding: { type: "pkcs8", format: "pem" }, } ); const cryptoPubKey = await importSPKI(publicKey, "RS256"); const jwk = await exportJWK(cryptoPubKey); jwk.kid = process.env.JWT_KID || "default-kid"; jwk.alg = "RS256"; jwk.use = "sig"; privateKey = privKey; publicJwk = jwk; // Save into .env const envPath = path.resolve(process.cwd(), ".env"); let envContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf-8") : ""; if (!envContent.includes("PRIVATE_KEY")) { envContent += `\nPRIVATE_KEY="${privateKey.replace(/\n/g, "\\n")}"`; } if (!envContent.includes("PUBLIC_JWK")) { envContent += `\nPUBLIC_JWK='${JSON.stringify(publicJwk)}'`; } fs.writeFileSync(envPath, envContent); console.log("βœ… Keys generated and stored in .env"); } return { privateKey, publicJwk }; }

Why This Matters:

LTI 1.3 uses asymmetric cryptography for secure communication. Your tool needs:

  • Private Key β†’ Used by your tool to sign JWTs when making requests back to Canvas (like grade passback)
  • Public Key (JWK) β†’ Shared with Canvas so it can verify your tool's signatures
  • Key ID (kid) β†’ Unique identifier for your key pair

The function:

  1. Checks if keys already exist in environment variables
  2. If not, generates a new RSA key pair (2048-bit for security)
  3. Converts the public key to JWK (JSON Web Key) format
  4. Saves both keys to your .env file for persistence
  5. Sets proper metadata (algorithm, usage, key ID)

4. JWKS Endpoint

// JWKS endpoint app.get("/jwks.json", async (req, res) => { const { publicJwk } = await initializeKeys(); res.json({ keys: [publicJwk] }); });

Purpose:

Canvas needs access to your tool's public key to verify JWT signatures. The JWKS (JSON Web Key Set) endpoint serves your public key in a standardized format.

  • Canvas will call https://yourtool.com/jwks.json to fetch your public key
  • This happens during the Developer Key configuration in Canvas
  • The endpoint returns a JSON object with your public key details
  • Canvas caches this key to verify future JWT signatures from your tool

When Canvas Uses This:

  • During initial tool registration/verification
  • When your tool sends signed requests (like grade submissions)
  • For ongoing security validation

5. LTI Login Endpoint

app.post("/lti/login", (req, res) => { try { const { iss, login_hint, target_link_uri, lti_message_hint, client_id, lti_deployment_id, } = req.body; if ( !iss || !login_hint || !target_link_uri || !client_id || !lti_deployment_id ) { return res.status(400).send("Missing required LTI parameters"); } const nonce = uuidv4(); const state = uuidv4(); nonceStore.set(state, { nonce, client_id, iss, lti_deployment_id, expiresAt: Date.now() + 10 * 60 * 1000, }); const authUrl = new URL(`${ISSUER}/api/lti/authorize_redirect`); authUrl.searchParams.append("scope", "openid"); authUrl.searchParams.append("response_type", "id_token"); authUrl.searchParams.append("response_mode", "form_post"); authUrl.searchParams.append("prompt", "none"); authUrl.searchParams.append("client_id", client_id); authUrl.searchParams.append("redirect_uri", `${TOOL_URL}/lti/launch`); authUrl.searchParams.append("login_hint", login_hint); authUrl.searchParams.append("state", state); authUrl.searchParams.append("nonce", nonce); authUrl.searchParams.append("lti_message_hint", lti_message_hint || ""); res.redirect(authUrl.toString()); } catch (error) { console.error("Login error:", error); res.status(500).send("Error during LTI login"); } });

Purpose:

This endpoint is called first by Canvas when a user launches your LTI tool. It starts the OIDC (OpenID Connect) login flow between Canvas and your tool.

State & Nonce:

  • state β†’ protects against CSRF attacks
  • nonce β†’ ensures the ID token you get later is not replayed
  • Both are stored temporarily in nonceStore

Redirect to Canvas Authorize URL:

Your tool builds a URL redirecting to Canvas /api/lti/authorize_redirect. Sends OIDC parameters:

  • scope=openid β†’ tells Canvas we want a secure ID token
  • response_type=id_token β†’ we expect an ID token in response
  • response_mode=form_post β†’ Canvas will POST the ID token to our tool
  • client_id β†’ identifies our tool in Canvas (from Developer Key)
  • redirect_uri β†’ where Canvas should send the ID token (/lti/launch)
  • login_hint β†’ helps Canvas identify the user/session
  • state + nonce β†’ for security checks

6. LTI 1.3 Launch Endpoint

app.post("/lti/launch", async (req, res) => { try { const { id_token, state } = req.body; if (!id_token || !state) throw new Error("Missing id_token or state"); // 1. Validate state + nonce const nonceData = nonceStore.get(state); if (!nonceData || nonceData.expiresAt < Date.now()) { return res.status(400).send("Invalid or expired state"); } nonceStore.delete(state); const [headerEncoded, payloadEncoded, signatureEncoded] = id_token.split("."); if (!headerEncoded || !payloadEncoded || !signatureEncoded) { throw new Error("Invalid JWT format"); } const header = JSON.parse( Buffer.from(headerEncoded, "base64url").toString() ); const payload = JSON.parse( Buffer.from(payloadEncoded, "base64url").toString() ); // 2. Verify nonce (and normally check aud, iss, deployment_id, exp here) if (payload.nonce !== nonceData.nonce) { return res.status(400).send("Invalid nonce"); } // 3. Fetch JWKS + verify signature const jwks = await (await fetch(`${ISSUER}/api/lti/security/jwks`)).json(); const key = jwks.keys.find((k) => k.kid === header.kid); if (!key) throw new Error(`No JWKS key found for kid: ${header.kid}`); const publicKey = await crypto.webcrypto.subtle.importKey( "jwk", key, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"] ); const isValid = await crypto.webcrypto.subtle.verify( { name: "RSASSA-PKCS1-v1_5" }, publicKey, Buffer.from(signatureEncoded, "base64url"), Buffer.from(`${headerEncoded}.${payloadEncoded}`) ); if (!isValid) throw new Error("Invalid JWT signature"); // 4. Extract user & course info const custom = payload["https://purl.imsglobal.org/spec/lti/claim/custom"] || {}; const userName = custom.user_name_full || payload.name || "User"; const userEmail = custom.user_email || payload.email || "N/A"; const courseName = custom.canvas_course_name || payload["https://purl.imsglobal.org/spec/lti/claim/context"]?.title; const userRoles = payload["https://purl.imsglobal.org/spec/lti/claim/roles"] .map((r) => r.split("#").pop()) .join(", "); res.send(` <!DOCTYPE html> <html> <head><title>LTI 1.3 Launch</title></head> <body> <h1>LTI 1.3 Launch Successful πŸŽ‰</h1> <p><strong>Hello, ${userName}!</strong></p> <p><strong>Email:</strong> ${userEmail}</p> <p><strong>Course:</strong> ${courseName}</p> <p><strong>Your Role(s):</strong> ${userRoles}</p> <h2>Full Launch Data:</h2> <pre>${JSON.stringify(payload, null, 2)}</pre> </body> </html> `); } catch (error) { console.error("Launch error:", error); res.status(500).send(`Error during LTI launch: ${error.message}`); } });

How It Works:

Canvas Calls /lti/launch: After /lti/login and the OIDC handshake, Canvas POSTs to /lti/launch with:

  • id_token β†’ JWT containing user, course, and tool data
  • state β†’ validates the request

State & Nonce Validation:

  • state ensures a legitimate login
  • nonce prevents token replay
  • Invalid or expired values are rejected

JWT Verification:

  • Split JWT into header, payload, signature
  • Fetch Canvas JWKS and verify the signature

Extract User & Course Info:

  • Standard claims: payload.name, payload.email, payload.context.title, payload.roles
  • Custom fields: payload['https://purl.imsglobal.org/spec/lti/claim/custom'] (e.g., user_name_full, canvas_course_name)
  • Set these in Canvas Developer Key under Custom Fields

Respond to the User: After verification, the tool can display info, launch content, or process grades. Example: success page showing user details and full JWT payload.

Notes:

  • Custom fields enable passing additional context from Canvas to your LTI tool
  • Example: user_name_full=$Person.name.full
  • For a comprehensive list of available custom fields, refer to the Canvas LTI Variable Substitutions documentation

7. Launching the LTI Tool Server

app.listen(PORT, async () => { await initializeKeys(); console.log(`LTI 1.3 Provider running on http://localhost:${PORT}`); });

The server initialization now includes key generation, ensuring your cryptographic setup is ready before accepting any LTI requests.

What We've Built

At this stage, our LTI 1.3 application is fully set up and operational locally.

The next step is to integrate this tool with Canvas LMS using a Developer Key, which will allow Canvas to securely launch the tool and exchange user and course information.

➑️ Up next: Integrating with Canvas LMS β€” follow this guide to complete the setup and enable secure launches from Canvas.