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

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.
How It Works
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.
Part 1 — Backend Setup
Install
npm install ltijs ltijs-sequelize
Three URLs to expose
| 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.js
const 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 FIRST
const setupLTI = require("./lti/ltijsProvider");
(async () => {
await setupLTI(app); // must come before all other routes
app.use(publicRoutes);
app.use(privateRoutes);
app.listen(8000);
})();
Helmet — allow iframe embedding
app.use(helmet({
contentSecurityPolicy: {
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
"frame-ancestors": ["'self'", "*"], // allow any LMS to embed you
},
},
}));
src/lti/lti_launch_controller.js
const 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 client
require("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"
Environment variables
LTI_ENCRYPTION_KEY=any-random-32-char-string
JWT_SECRET=your-existing-jwt-secret
FRONTEND_URL=https://yourportal.com
Part 2 — Moodle Configuration
Step 1 — Add External Tool
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.
Step 2 — Paste Client ID and register
Paste the Client ID from Moodle into registerLTIPlatform.js, then:
npm run register-lti-platform
Step 3 — Add to a course
Course → Turn editing on → Add activity → External Tool → select your tool.
Students clicking it will trigger the full launch.
Part 3 — Token Normalizer
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;
Part 4 — Admin API for Managing Platforms
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.
Part 5 — Per-Course Config (Route users to specific content)
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.
Common Gotchas
Cookies blocked
- Use
sameSite: "None"+secure: truein production - API must be on HTTPS —
sameSite: Nonerequires it - In dev: use
devMode: truein ltijs (disables the state cookie check)
"Platform not found" error
- The
urlyou register must match Moodle'sissclaim exactly — no trailing slash difference, same protocol
POST /lti never arrives
- Add a log before
app.use("/lti", lti.app)to confirm the POST hits your server - Usually caused by an iframe blocking the cross-origin form_post redirect from Moodle
No email from Moodle
- Check Moodle tool Privacy settings → Share launcher's email → Always
- If still empty, generate a placeholder:
`no-email+${Date.now()}@placeholder.com`
Pre-Launch Checklist
-
LTI_ENCRYPTION_KEYis 32+ chars and different fromJWT_SECRET - API is on HTTPS
-
cookies.secure = trueandsameSite = "None"in production -
devMode: falsein production - Helmet
frame-ancestorsallows the client's Moodle domain - Platform registered in DB via
npm run register-lti-platform - Moodle Privacy → Share name + email → Always
- Moodle Redirection URI matches your
/ltiURL exactly -
lti_client_idstored on the client record in your database





