Implementing LTI 1.3 in a Node.js Portal (Moodle Sandbox Walkthrough)
The Scenario

Search for a command to run...
The Scenario

No comments yet. Be the first to comment.
Introduction In this guide, we'll walk through setting up HTTPS for your website using AWS Certificate Manager (ACM) and CloudFront with an EC2 instance. This approach provides free SSL certificates, CDN benefits, and robust security. Prerequisites ...

Introduction:Setting up and hosting a Node server on AWS EC2 can be a powerful and flexible solution for deploying your web applications. In this step-by-step guide, we will cover the entire process, from creating an EC2 instance to configuring Nginx...

Introduction: In today's digital era, having a website is essential for businesses and individuals alike. While there are many ways to host a website, Amazon Web Services (AWS) provides a reliable and scalable infrastructure for hosting websites of a...

Why Following Rules Isn't Enough: The Importance of Standards in Development

You've built a training portal. A client says:
"We use Moodle. Can our employees click a link inside a Moodle course and land in your portal — already logged in — without a separate signup?"
That is exactly what LTI 1.3 solves.
Step 1 — Student clicks the tool link inside their Moodle course.
Step 2 — Moodle sends a login hint to your server: GET https://yourapi.com/lti/login
Step 3 — Your server redirects back to Moodle's auth endpoint to request a signed token.
Step 4 — Moodle POSTs a signed id_token (JWT) to your server: POST https://yourapi.com/lti
Step 5 — Your server validates the token, finds or creates the user, mints your own app JWT, and redirects to your frontend.
Step 6 — Student lands on your portal — already authenticated, no password entered.
npm install ltijs ltijs-sequelize
| Moodle calls it | Your URL | Purpose |
|---|---|---|
| Tool URL | https://yourapi.com/lti |
Receives the signed token |
| Login Initiation URL | https://yourapi.com/lti/login |
Starts OIDC flow |
| Public Keyset URL | https://yourapi.com/lti/keys |
Your JWKS public keys |
src/lti/ltijsProvider.jsconst lti = require("ltijs").Provider;
const Database = require("ltijs-sequelize");
const ltiLaunchController = require("./lti_launch_controller");
async function setupLTI(app) {
const db = new Database(
process.env.DB_NAME, process.env.DB_USER, process.env.DB_PASSWORD,
{ host: process.env.DB_HOST, dialect: "postgres", logging: false }
);
await lti.setup(
process.env.LTI_ENCRYPTION_KEY,
{ plugin: db },
{
appRoute: "/", // relative to /lti mount → /lti
loginRoute: "/login", // → /lti/login
keysetRoute: "/keys", // → /lti/keys
cookies: {
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
httpOnly: true,
},
devMode: process.env.NODE_ENV !== "production",
tokenMaxAge: 3600,
}
);
lti.onConnect(async (token, req, res) => {
ltiLaunchController.ltiLaunch(req, res, token);
});
await lti.deploy({ serverless: true });
app.use("/lti", lti.app);
}
module.exports = setupLTI;
src/app.js — mount ltijs FIRSTconst setupLTI = require("./lti/ltijsProvider");
(async () => {
await setupLTI(app); // must come before all other routes
app.use(publicRoutes);
app.use(privateRoutes);
app.listen(8000);
})();
app.use(helmet({
contentSecurityPolicy: {
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
"frame-ancestors": ["'self'", "*"], // allow any LMS to embed you
},
},
}));
src/lti/lti_launch_controller.jsconst jwt = require("jsonwebtoken");
const User = require("../models/user");
const Client = require("../models/client");
const Role = require("../models/role");
const LtiSession = require("../models/lti_session");
const db = require("../config/database");
const ltiLaunch = async (req, res, token) => {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const transaction = await db.transaction();
try {
// 1. Extract user info from the LTI token
const email = token.userInfo?.email || token.email || "";
const name = token.userInfo?.name || token.name || "LTI User";
const ltiUserId = token.user || token.sub;
const platformId = token.platformId || token.iss;
const ltiClientId = token.clientId || token.aud;
// 2. Match LTI client_id to a client record in your database
// (you store the client_id Moodle gave you when registering the tool)
const client = await Client.findOne({ where: { lti_client_id: ltiClientId } });
if (!client) {
await transaction.rollback();
return res.redirect(`${frontendUrl}/lti-error?errorCode=CLIENT_NOT_FOUND`);
}
// 3. Find existing user by their LTI identity, or auto-create on first launch
let user = await User.findOne({
where: { lti_user_id: ltiUserId, lti_platform_id: platformId },
});
if (!user) {
const role = await Role.findOne({ where: { name: "lti_user" } });
user = await User.create({
name,
email: email || `no-email+${Date.now()}@placeholder.com`,
username: `lti-${email || Date.now()}`,
lti_user_id: ltiUserId,
lti_platform_id: platformId,
role_id: role.id,
active: true,
}, { transaction });
// Link the user to the client they launched from
await user.addClient(client, { transaction });
}
// 4. Record the LTI session (useful for audit and content scoping)
const ltiSession = await LtiSession.create({
user_id: user.id,
platform_id: platformId,
context_id: token["https://purl.imsglobal.org/spec/lti/claim/context"]?.id,
context_title: token["https://purl.imsglobal.org/spec/lti/claim/context"]?.title,
session_started_at: new Date(),
}, { transaction });
await transaction.commit();
// 5. Mint your own app JWT and send the user to the frontend
const appToken = jwt.sign(
{ userId: user.id, lti_session_id: ltiSession.id },
process.env.JWT_SECRET,
{ expiresIn: "1h" }
);
return res.redirect(`\({frontendUrl}?token=\){appToken}`);
} catch (err) {
await transaction.rollback();
console.error("[LTI] Launch error:", err);
return res.redirect(`${frontendUrl}/lti-error?errorCode=INTERNAL`);
}
};
module.exports = { ltiLaunch };
src/lti/registerLTIPlatform.js — run once per LMS clientrequire("dotenv").config();
const lti = require("ltijs").Provider;
const Database = require("ltijs-sequelize");
(async () => {
const db = new Database(
process.env.DB_NAME, process.env.DB_USER, process.env.DB_PASSWORD,
{ host: process.env.DB_HOST, dialect: "postgres", logging: false }
);
await lti.setup(process.env.LTI_ENCRYPTION_KEY, { plugin: db }, {
appRoute: "/lti", loginRoute: "/lti/login", keysetRoute: "/lti/keys",
});
await lti.deploy({ serverless: true });
await lti.registerPlatform({
url: "https://sandbox.moodledemo.net", // issuer — must match exactly, no trailing slash
name: "Moodle Sandbox",
clientId: "PASTE_CLIENT_ID_FROM_MOODLE_HERE",
authenticationEndpoint: "https://sandbox.moodledemo.net/mod/lti/auth.php",
accesstokenEndpoint: "https://sandbox.moodledemo.net/mod/lti/token.php",
authConfig: {
method: "JWK_SET",
key: "https://sandbox.moodledemo.net/mod/lti/certs.php",
},
});
console.log("Platform registered.");
process.exit(0);
})();
Add to package.json:
"register-lti-platform": "node src/lti/registerLTIPlatform.js"
LTI_ENCRYPTION_KEY=any-random-32-char-string
JWT_SECRET=your-existing-jwt-secret
FRONTEND_URL=https://yourportal.com
Go to: Site administration > Plugins > Activity modules > External tool > Manage tools
Then click "configure a tool manually".
| Field | Value |
|---|---|
| Tool name | My Training Portal |
| Tool URL | https://yourapi.com/lti |
| LTI version | LTI 1.3 |
| Public key type | Keyset URL |
| Public keyset | https://yourapi.com/lti/keys |
| Initiate login URL | https://yourapi.com/lti/login |
| Redirection URI(s) | https://yourapi.com/lti |
Under Privacy: set both name and email sharing to Always.
Save — Moodle generates a Client ID. Copy it.
Paste the Client ID from Moodle into registerLTIPlatform.js, then:
npm run register-lti-platform
Course → Turn editing on → Add activity → External Tool → select your tool.
Students clicking it will trigger the full launch.
Different LMS platforms (Moodle, Canvas, Blackboard) put claims in slightly different places. Write a normalizer once so your launch controller never needs to care which LMS sent the token:
// src/lti/ltiTokenNormalizer.js
class LTITokenNormalizer {
static normalize(token) {
return {
user: {
id: token.user || token.sub,
name: token.userInfo?.name || token.name || "Unknown",
email: token.userInfo?.email || token.email || "",
},
platform: {
id: token.platformId || token.iss,
clientId: token.clientId || token.aud,
deploymentId: token["https://purl.imsglobal.org/spec/lti/claim/deployment_id"],
},
context: {
id: token["https://purl.imsglobal.org/spec/lti/claim/context"]?.id,
title: token["https://purl.imsglobal.org/spec/lti/claim/context"]?.title,
},
custom: token["https://purl.imsglobal.org/spec/lti/claim/custom"] || {},
};
}
}
module.exports = LTITokenNormalizer;
So you never need to redeploy to connect a new client's LMS:
| Method | Route | Action |
|---|---|---|
| POST | /admin/lti/platforms |
Register new platform |
| GET | /admin/lti/platforms |
List all platforms |
| PUT | /admin/lti/platforms/:id |
Update platform |
| DELETE | /admin/lti/platforms/:id |
Remove platform |
// POST /admin/lti/platforms
const registerPlatform = async (req, res) => {
const { url, name, clientId, authenticationEndpoint, accesstokenEndpoint, keysetUrl } = req.body;
const platform = await lti.registerPlatform({
url, name, clientId,
authenticationEndpoint,
accesstokenEndpoint,
authConfig: { method: "JWK_SET", key: keysetUrl },
});
return res.json({ success: true, platformId: await platform.platformId() });
};
// GET /admin/lti/platforms
const getAllPlatforms = async (req, res) => {
const platforms = await lti.getAllPlatforms();
const data = await Promise.all(platforms.map(async p => ({
id: await p.platformId(),
url: await p.platformUrl(),
name: await p.platformName(),
clientId: await p.platformClientId(),
})));
return res.json({ success: true, data });
};
Protect these routes with a super-admin guard — only your own team should touch them.
Clients want: "When a student launches from the Sales course, open the Sales scenario."
Store a lti_course_configs table:
| column | meaning |
|---|---|
lti_client_id |
which LMS installation |
deployment_id |
which site instance |
context_id |
which course |
scenario_code |
scenario to open |
group_id |
group to auto-enrol into |
At launch, do a scored lookup — most specific match wins:
const match =
configs.find(c => c.deployment_id === deployId && c.context_id === contextId) ||
configs.find(c => c.deployment_id === deployId && !c.context_id) ||
configs.find(c => c.context_id === contextId && !c.deployment_id);
if (match?.group_id) {
await user.addGroup(match.group_id, { transaction });
}
// store match.scenario_code in the LTI session so the frontend opens it directly
The deployment_id + context_id pair uniquely identifies a course inside a specific Moodle installation. Moodle sends both in every launch token.
Cookies blocked
sameSite: "None" + secure: true in productionsameSite: None requires itdevMode: true in ltijs (disables the state cookie check)"Platform not found" error
url you register must match Moodle's iss claim exactly — no trailing slash difference, same protocolPOST /lti never arrives
app.use("/lti", lti.app) to confirm the POST hits your serverNo email from Moodle
`no-email+${Date.now()}@placeholder.com`LTI_ENCRYPTION_KEY is 32+ chars and different from JWT_SECRETcookies.secure = true and sameSite = "None" in productiondevMode: false in productionframe-ancestors allows the client's Moodle domainnpm run register-lti-platform/lti URL exactlylti_client_id stored on the client record in your database