DEV Community

Geminate Solutions
Geminate Solutions

Posted on

eLearning Software Development: Accept LTI 1.3 Launches in Node.js

In eLearning software development, getting your product to run inside Moodle or Canvas means accepting an LTI 1.3 launch. In Node.js, that takes three steps: answer an OIDC login request, verify a signed JWT against the platform's public keys, and turn the claims inside it into your own users and courses. The hard part is the handful of checks that stop one school's teacher from landing in another school's data.

This walkthrough is for you if a district just asked "does it work with Canvas?" and your team is now reading the IMS spec.

What actually happens during an LTI 1.3 launch?

A launch is an OpenID Connect login. The LMS is the identity provider and your app is the client. The LMS starts the flow, your app sends the browser back with a state and a nonce, and the LMS returns a signed id_token through a form POST.

In order:

  1. The LMS sends the browser to your login URL with iss, login_hint, target_link_uri and usually client_id and lti_message_hint.
  2. Your app stores a state and a nonce, then redirects to the platform's authorization endpoint.
  3. The platform POSTs id_token and state to your launch URL.
  4. You verify the token, run the LTI checks and start a session.

In Moodle, the endpoints are under /mod/lti/: auth.php handles authorization and certs.php serves the key set. On Canvas cloud the issuer is https://canvas.instructure.com and the endpoints are on sso.canvaslms.com. Every Canvas cloud customer shares that one issuer, so client_id is how you tell registrations apart.

What does a sloppy launch cost you?

Most launch bugs fail silently. They let the wrong person in, or they quietly create duplicate accounts.

Without a nonce check, someone who captures an id_token can replay it and sign in as that teacher until the token expires. Without a deployment ID check, any Canvas account that installs your developer key gets in, including schools that never signed a contract. If you match users on email, accounts get mixed up when a school hides emails or a student changes theirs.

Each of these ends up as a ticket from a school IT director, and that IT director has a say in your renewal.

Do you need to build this yourself?

Not always. If you connect to a single Moodle site you control, a maintained library such as ltijs will get you there faster than writing it by hand.

Write it yourself when you serve many institutions, own your user model or need to audit exactly what you trust. The code below uses Express, jose and Redis.

Step 1: handle the OIDC login request

import express from 'express'
import cookieParser from 'cookie-parser'
import crypto from 'node:crypto'
import Redis from 'ioredis'

const app = express()
const redis = new Redis(process.env.REDIS_URL)
app.use(express.urlencoded({ extended: false }))
app.use(cookieParser())

// One entry per registration. Canvas cloud shares one issuer, so match on client_id too.
const platforms = [{
  issuer: 'https://moodle.example.edu',
  clientId: 'moodle-client-id',
  authUrl: 'https://moodle.example.edu/mod/lti/auth.php',
  jwksUrl: 'https://moodle.example.edu/mod/lti/certs.php',
  deploymentIds: ['1'],
}]

const findPlatform = (iss, clientId) =>
  platforms.find(p => p.issuer === iss && p.clientId === clientId)

app.all('/lti/login', async (req, res) => {
  const p = { ...req.query, ...req.body }
  const platform = findPlatform(p.iss, p.client_id)
  if (!platform) return res.status(400).send('Unknown platform')

  const state = crypto.randomUUID()
  const nonce = crypto.randomUUID()
  await redis.set(`lti:${state}`,
    JSON.stringify({ nonce, iss: platform.issuer, clientId: platform.clientId }), 'EX', 600)
  res.cookie('lti_state', state, { httpOnly: true, secure: true, sameSite: 'none', maxAge: 600_000 })

  const url = new URL(platform.authUrl)
  url.search = new URLSearchParams({
    scope: 'openid', response_type: 'id_token', response_mode: 'form_post',
    prompt: 'none', client_id: platform.clientId,
    redirect_uri: 'https://tool.example.com/lti/launch',
    login_hint: p.login_hint, state, nonce,
    ...(p.lti_message_hint && { lti_message_hint: p.lti_message_hint }),
  })
  res.redirect(url.toString())
})
Enter fullscreen mode Exit fullscreen mode

Two details matter here. First, the state cookie has to be SameSite=None and Secure. The launch arrives as a cross-site POST, and a default Lax cookie is not sent back on it. Second, platforms can send the login as either GET or POST, so app.all accepts both. If a platform leaves out client_id, fall back to matching on the issuer alone, but only when you have exactly one registration for that issuer.

How do you verify the id_token against the platform's JWKS?

Use createRemoteJWKSet from jose and let jwtVerify check the signature, issuer, audience and expiry. The key set is fetched once and cached. It is fetched again when a token arrives with a key ID it has not seen, so key rotation on the platform side needs no work from you.

import { createRemoteJWKSet, jwtVerify } from 'jose'

const keySets = new Map()
const keySet = url => {
  if (!keySets.has(url)) keySets.set(url, createRemoteJWKSet(new URL(url)))
  return keySets.get(url)
}
const LTI = 'https://purl.imsglobal.org/spec/lti/claim/'

app.post('/lti/launch', async (req, res) => {
  const { id_token, state } = req.body
  if (!state || state !== req.cookies.lti_state) return res.status(401).send('State mismatch')

  const saved = await redis.getdel(`lti:${state}`)
  if (!saved) return res.status(401).send('Login expired or already used')
  const { nonce, iss, clientId } = JSON.parse(saved)
  const platform = findPlatform(iss, clientId)

  let claims
  try {
    ({ payload: claims } = await jwtVerify(id_token, keySet(platform.jwksUrl), {
      issuer: platform.issuer, audience: platform.clientId, clockTolerance: 30,
    }))
  } catch {
    return res.status(401).send('Invalid token')
  }

  if (claims.nonce !== nonce) return res.status(401).send('Nonce mismatch')
  if (Array.isArray(claims.aud) && claims.aud.length > 1 && claims.azp !== platform.clientId)
    return res.status(401).send('Bad azp')
  if (claims[LTI + 'version'] !== '1.3.0') return res.status(400).send('Wrong LTI version')
  if (claims[LTI + 'message_type'] !== 'LtiResourceLinkRequest')
    return res.status(400).send('Wrong message type')
  const deploymentId = claims[LTI + 'deployment_id']
  if (!platform.deploymentIds.includes(deploymentId))
    return res.status(403).send('Unknown deployment')

  // mapping continues below
})
Enter fullscreen mode Exit fullscreen mode

Which checks does most sample code leave out?

A valid signature only proves the LMS issued the token. It does not prove the token is fresh, that it belongs to this login, or that it comes from a deployment you agreed to serve. Each of those needs its own check.

  • Nonce, used once. Compare it with the stored value and delete the record in the same step. GETDEL does both, so posting the same token a second time fails.
  • State tied to the browser. The cookie and the form field must match. Otherwise an attacker can push their own launch into someone else's browser session.
  • Deployment ID allowlist. One Canvas developer key can be installed in many sub-accounts, and each install gets its own deployment_id. Accept only the ones you registered.
  • azp when aud is an array. If the audience has more than one value, azp must equal your client ID.

The message_type check matters as well. Without it, a deep-linking message could reach your resource launch handler.

How do you map launch claims to your own users and courses?

Identify users by iss plus sub. Identify courses by iss, deployment_id and the context id. Names, emails and course titles are display data: update them on every launch and never use them as identifiers.

// inside the launch handler, after the checks. `db` is your own data layer.
const user = await db.users.upsert({
  where: { issuer: claims.iss, subject: claims.sub },
  set: { name: claims.name, email: claims.email ?? null },
})
const context = claims[LTI + 'context'] ?? {}
const course = await db.courses.upsert({
  where: { issuer: claims.iss, deploymentId, contextId: context.id },
  set: { title: context.title },
})
const roles = claims[LTI + 'roles'] ?? []
const isTeacher = roles.some(r => /#(Instructor|Administrator)$/.test(r))
await db.enrolments.upsert({ userId: user.id, courseId: course.id, role: isTeacher ? 'teacher' : 'student' })

const target = new URL(claims[LTI + 'target_link_uri'])
if (target.origin !== 'https://tool.example.com') return res.status(400).send('Bad target')
req.session.userId = user.id
res.redirect(target.pathname + target.search)
Enter fullscreen mode Exit fullscreen mode

If a teacher runs the same course at two schools, they get two course records. That is the correct result. Roles arrive as full URIs, so match on the suffix and not on the bare word Instructor. Some launches come from outside a course and have no context claim, so skip the course step when it is missing.

How do you test against Moodle and Canvas before a school does?

Run Moodle locally in Docker, register your app as an External tool and launch it from a course. Then do the same in a Canvas test instance.

Run four tests on each platform: post the same launch twice (the second must fail), launch from a deployment you have not registered (expect a 403), launch once as a student and once as a teacher, and launch as a user with no email. Then try Safari. When your tool runs in an iframe, Safari's third-party cookie blocking can drop your state cookie. The fix is to open the tool in a new window or to support the LTI platform storage messages that Canvas offers.

Where does the launch fit in eLearning software development?

The launch only gets people in the door. Behind it come grade passback through Assignment and Grade Services, roster sync through Names and Role Provisioning, and the tenant model that keeps each school's data separate. If your identity keys are right from day one, those later pieces stay simple.

Geminate Solutions has built EdTech systems, including an EdTech platform with 250K+ daily active users. The LMS integration decisions made early tend to shape whatever comes after them.

If you are planning bigger changes, read the EdTech platform rewrite guide, or send Geminate Solutions your launch flow for a free review, with a reply within 24 hours.

Top comments (0)