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 portjoselibrary β 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:
- Checks if keys already exist in environment variables
- If not, generates a new RSA key pair (2048-bit for security)
- Converts the public key to JWK (JSON Web Key) format
- Saves both keys to your
.envfile for persistence - 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.jsonto 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 attacksnonceβ 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 tokenresponse_type=id_tokenβ we expect an ID token in responseresponse_mode=form_postβ Canvas will POST the ID token to our toolclient_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/sessionstate + 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 datastateβ validates the request
State & Nonce Validation:
stateensures a legitimate loginnonceprevents 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.