Skip to main content

Command Palette

Search for a command to run...

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

The Scenario

Published
8 min read
Implementing LTI 1.3 in a Node.js Portal (Moodle Sandbox Walkthrough)

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: true in production
  • API must be on HTTPS — sameSite: None requires it
  • In dev: use devMode: true in ltijs (disables the state cookie check)

"Platform not found" error

  • The url you register must match Moodle's iss claim 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_KEY is 32+ chars and different from JWT_SECRET
  • API is on HTTPS
  • cookies.secure = true and sameSite = "None" in production
  • devMode: false in production
  • Helmet frame-ancestors allows 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 /lti URL exactly
  • lti_client_id stored on the client record in your database
3 views