A deep dive into LTI 1.3 — the OIDC-based protocol that connects learning tools to Canvas, Moodle, and Blackboard. Covers the three-step launch flow, JWT anatomy, ltijs implementation, NRPS roster access, and AGS grade passback.
Tyler McDaniel
AI Engineer & IBM Business Partner
If you build software for education, you will implement LTI. There is no alternative. Every LMS — Canvas, Blackboard, Moodle, Brightspace, Schoology — speaks LTI, and if your tool doesn't, it doesn't get adopted. I've built LTI integrations for four different EdTech products, and LTI 1.3 is a genuine improvement over 1.1, but the documentation landscape is a minefield of outdated examples, incomplete specs, and vendor-specific quirks that nobody warns you about.
Understanding LTI 1.3 integration means understanding OIDC authentication, JWT message signing, platform/tool registration, and the service APIs that let you sync grades, rosters, and content links. This guide covers all of it with working code.
LTI 1.1 used OAuth 1.0a for authentication. Every launch was a form POST with a signature calculated from a shared secret. It worked, but:
LTI 1.3, published by [1EdTech](https://www.1edtech.org/standards/lti) (formerly IMS Global), replaces all of this with:
The tradeoff: LTI 1.3 is significantly more complex to implement. LTI 1.1 was a single form POST. LTI 1.3 is a multi-step OIDC flow with key management, nonce validation, and JWT parsing. The security improvement is worth it, but the implementation cost is real.
The LTI 1.3 launch is a three-step OIDC third-party initiated login. Here's exactly what happens:
The LMS (platform) sends a GET or POST to your tool's login URL with:
GET https://your-tool.com/lti/login?
iss=https://canvas.instructure.com
&login_hint=abc123
&target_link_uri=https://your-tool.com/lti/launch
<i_message_hint=xyz789
&client_id=10000000000001
iss — the platform's issuer identifierlogin_hint — opaque string identifying the user (you don't decode this)target_link_uri — where the user should end up after authenticationclient_id — your tool's registration ID on this platformYour tool does NOT authenticate the user here. You redirect them to the platform's authorization endpoint.
Your tool redirects the browser to the platform's authorization endpoint:
GET https://canvas.instructure.com/api/lti/authorize_redirect?
scope=openid
&response_type=id_token
&client_id=10000000000001
&redirect_uri=https://your-tool.com/lti/launch
&login_hint=abc123
<i_message_hint=xyz789
&state=random-csrf-token
&nonce=unique-nonce-value
&response_mode=form_post
&prompt=none
Critical security parameters:
state — CSRF protection. A random, unguessable value that you store in the session. You verify it in Step 3.nonce — replay protection. A unique value per request. You verify it appears in the returned JWT and hasn't been used before.response_mode=form_post — the platform will POST the id_token back, not append it to the URL (which would leak it in browser history and server logs)The platform authenticates the user (they're already logged into the LMS), then POSTs back to your redirect_uri:
POST https://your-tool.com/lti/launch
Content-Type: application/x-www-form-urlencoded
state=random-csrf-token
&id_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ijk4YT...
Your tool must:
state matches what you stored in Step 2. If not, reject — it's a CSRF attack.id_token):- Fetch the platform's public keys from their JWKS endpoint
- Verify the signature (RSA256 typically)
- Check iss matches the expected platform
- Check aud contains your client_id
- Check nonce matches what you sent and hasn't been used before
- Check exp hasn't passed
- Check iat is recent (I reject anything older than 5 minutes)
The JWT payload contains everything about the launch:
{
"iss": "https://canvas.instructure.com",
"sub": "user-id-12345",
"aud": "10000000000001",
"exp": 1719432000,
"iat": 1719431700,
"nonce": "unique-nonce-value",
"https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest",
"https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0",
"https://purl.imsglobal.org/spec/lti/claim/roles": [
"http://purl.imsglobal.org/vocab/lis/v2/membership#Learner",
"http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student"
],
"https://purl.imsglobal.org/spec/lti/claim/context": {
"id": "course-456",
"label": "CS 101",
"title": "Introduction to Computer Science",
"type": ["http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering"]
},
"https://purl.imsglobal.org/spec/lti/claim/resource_link": {
"id": "link-789",
"title": "Week 3 Quiz"
},
"https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice": {
"context_memberships_url": "https://canvas.instructure.com/api/lti/courses/456/memberships",
"service_versions": ["2.0"]
},
"https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": {
"scope": [
"https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
"https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly",
"https://purl.imsglobal.org/spec/lti-ags/scope/score"
],
"lineitems": "https://canvas.instructure.com/api/lti/courses/456/line_items"
},
"name": "Jane Student",
"email": "jane@university.edu"
}
Those https://purl.imsglobal.org/spec/lti/claim/* keys are the LTI-specific claims. The roles array tells you if the user is an instructor, student, TA, or admin. The context tells you which course. The resource_link tells you which specific content item was clicked. The AGS and NRPS endpoints tell you where to send grades and fetch rosters.
[ltijs](https://github.com/Cvmcosta/ltijs) is the most complete Node.js library for LTI 1.3. It handles the OIDC flow, JWT validation, key management, and all three service APIs. Here's a complete integration:
npm install ltijs express dotenv
// lti-server.ts
import { Provider } from 'ltijs';
import path from 'path';
const lti = Provider;
// Initialize ltijs with database connection for session/nonce storage
lti.setup(
process.env.LTI_ENCRYPTION_KEY!, // Encryption key for cookies/sessions
{
url: process.env.MONGODB_URI!, // MongoDB for nonce/session storage
connection: {
user: process.env.MONGODB_USER,
pass: process.env.MONGODB_PASS,
},
},
{
appUrl: '/', // Default launch redirect
loginUrl: '/lti/login', // OIDC login initiation endpoint
cookies: {
secure: true, // HTTPS only in production
sameSite: 'None', // Required for cross-origin LMS iframes
},
devMode: process.env.NODE_ENV !== 'production',
}
);
// Handle successful LTI launch
lti.onConnect(async (token, req, res) => {
// token contains the decoded JWT claims
const userId = token.userInfo?.sub;
const roles = token.platformContext?.roles || [];
const courseId = token.platformContext?.context?.id;
const courseName = token.platformContext?.context?.title;
const resourceId = token.platformContext?.resource?.id;
const isInstructor = roles.some((role: string) =>
role.includes('Instructor') || role.includes('Administrator')
);
console.log(LTI Launch: user=${userId}, course=${courseId}, instructor=${isInstructor});
// Redirect to your app with the LTI context
if (isInstructor) {
return res.redirect(/dashboard?course=${courseId}&resource=${resourceId});
}
return res.redirect(/activity?course=${courseId}&resource=${resourceId});
});
// Handle deep linking requests (instructor selects content to embed)
lti.onDeepLinking(async (token, req, res) => {
// Return a page where the instructor can browse and select content
return res.redirect('/content-picker');
});
// API route: Send a grade back to the LMS
lti.app.post('/api/grade', async (req, res) => {
try {
const { userId, score, maxScore, activityId, comment } = req.body;
// Get the LTI token from the session
const token = res.locals.token;
// Use Assignment and Grade Services to send the score
const grade = {
userId: userId,
scoreGiven: score,
scoreMaximum: maxScore,
activityProgress: 'Completed',
gradingProgress: 'FullyGraded',
comment: comment || undefined,
timestamp: new Date().toISOString(),
};
const result = await lti.Grade.submitScore(
token,
grade,
{
resourceLinkId: true, // Post to the resource link's line item
}
);
res.json({ success: true, result });
} catch (error) {
console.error('Grade submission failed:', error);
res.status(500).json({ success: false, error: 'Grade submission failed' });
}
});
// API route: Fetch course roster via NRPS
lti.app.get('/api/roster', async (req, res) => {
try {
const token = res.locals.token;
const members = await lti.NamesAndRoles.getMembers(token);
const roster = members.members.map((member: any) => ({
id: member.user_id,
name: member.name,
email: member.email,
roles: member.roles,
status: member.status,
}));
res.json({ members: roster });
} catch (error) {
console.error('Roster fetch failed:', error);
res.status(500).json({ error: 'Failed to fetch roster' });
}
});
// API route: Create a deep link response
lti.app.post('/api/deep-link', async (req, res) => {
try {
const { items } = req.body;
const token = res.locals.token;
const resources = items.map((item: any) => ({
type: 'ltiResourceLink',
title: item.title,
url: ${process.env.APP_URL}/activity/${item.id},
custom: {
activity_id: item.id,
activity_type: item.type,
},
}));
const deepLinkingMessage = await lti.DeepLinking.createDeepLinkingMessage(
token,
resources,
{ message: 'Content successfully linked!' }
);
res.json({ deepLinkingMessage });
} catch (error) {
console.error('Deep linking failed:', error);
res.status(500).json({ error: 'Failed to create deep link' });
}
});
// Start the server
const PORT = process.env.PORT || 3000;
lti.deploy({ port: PORT }).then(() => {
console.log(LTI tool running on port ${PORT});
// Register platforms (do this once, or via admin UI)
registerPlatforms();
});
async function registerPlatforms() {
// Canvas
await lti.registerPlatform({
url: 'https://canvas.instructure.com',
name: 'Canvas',
clientId: process.env.CANVAS_CLIENT_ID!,
authenticationEndpoint: 'https://canvas.instructure.com/api/lti/authorize_redirect',
accesstokenEndpoint: 'https://canvas.instructure.com/login/oauth2/token',
authConfig: {
method: 'JWK_SET',
key: 'https://canvas.instructure.com/api/lti/security/jwks',
},
});
// Moodle
await lti.registerPlatform({
url: process.env.MOODLE_URL!,
name: 'Moodle',
clientId: process.env.MOODLE_CLIENT_ID!,
authenticationEndpoint: ${process.env.MOODLE_URL}/mod/lti/auth.php,
accesstokenEndpoint: ${process.env.MOODLE_URL}/mod/lti/token.php,
authConfig: {
method: 'JWK_SET',
key: ${process.env.MOODLE_URL}/mod/lti/certs.php,
},
});
// Blackboard
await lti.registerPlatform({
url: 'https://blackboard.com',
name: 'Blackboard',
clientId: process.env.BB_CLIENT_ID!,
authenticationEndpoint: 'https://developer.blackboard.com/api/v1/gateway/oidcauth',
accesstokenEndpoint: 'https://developer.blackboard.com/api/v1/gateway/oauth2/jwttoken',
authConfig: {
method: 'JWK_SET',
key: 'https://developer.blackboard.com/api/v1/management/applications/jwks',
},
});
}
This is a working server. The key things ltijs handles for you:
SameSite=None cookies required for LMS iframe embeddingAGS lets you create grade columns (line items) and submit scores. The flow:
The access token request is the part that surprises most developers. LTI 1.3 service calls use a separate OAuth 2.0 flow — your tool authenticates to the platform's token endpoint by signing a JWT with your private key. This is the [client_credentials grant with private_key_jwt](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication) authentication method.
POST /login/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzI1Ni...
&scope=https://purl.imsglobal.org/spec/lti-ags/scope/score
ltijs does this automatically when you call lti.Grade.submitScore(), but understanding the underlying flow is critical for debugging. When grade submission fails, it's usually:
NRPS gives you the course roster — user IDs, names, emails, and roles. This is essential for:
The membership endpoint supports pagination. For large courses (500+ students), you must follow Link headers:
async function getAllMembers(token: any): Promise<any[]> {
let allMembers: any[] = [];
let result = await lti.NamesAndRoles.getMembers(token);
allMembers = allMembers.concat(result.members);
while (result.next) {
result = await lti.NamesAndRoles.getMembers(token, { pages: result.next });
allMembers = allMembers.concat(result.members);
}
return allMembers;
}
Deep Linking lets instructors browse your tool's content and select specific items to embed in their course. The flow:
LtiDeepLinkingRequest launch (instead of LtiResourceLinkRequest)The response JWT must be signed with your tool's private key. The platform validates it before creating the content links. This is the one place where your tool creates a JWT instead of just validating one.
Every LMS has a slightly different registration process. Here's what you actually need to know:
client_id — save itCanvas-specific gotcha: Canvas sends iss as https://canvas.instructure.com for all hosted Canvas instances, but the authorization endpoint varies by institution's Canvas URL. You need to handle both the canonical issuer and the per-institution launch URL. Refer to the [Canvas LTI Developer Key documentation](https://canvas.instructure.com/doc/api/file.lti_dev_key_config.html) for the current setup flow.
Moodle-specific gotcha: Moodle 4.x changed the JWKS endpoint path. If you're supporting institutions on different Moodle versions, you may need to handle both /mod/lti/certs.php and /mod/lti/jwks.php.
Blackboard-specific gotcha: Blackboard's token endpoint returns errors in a non-standard format. Your error handling needs to account for their response schema.
Every platform needs the same information from your tool:
| Field | Description | Example |
|-------|-------------|---------|
| Login URL | OIDC login initiation | https://your-tool.com/lti/login |
| Redirect URI(s) | OIDC callback(s) | https://your-tool.com/lti/launch |
| JWKS URL | Your public keys | https://your-tool.com/.well-known/jwks.json |
| Deep Linking URL | Content picker | https://your-tool.com/lti/deeplink |
| Domain | Tool domain | your-tool.com |
| Scopes | Requested permissions | AGS, NRPS, Deep Linking |
And your tool needs from each platform:
| Field | Description | Where to find |
|-------|-------------|--------------|
| Issuer (iss) | Platform identifier | Platform docs / registration UI |
| Client ID | Your registration ID | Generated during registration |
| Authorization endpoint | OIDC auth URL | Platform docs / .well-known |
| Token endpoint | OAuth token URL | Platform docs / .well-known |
| JWKS URL | Platform public keys | Platform docs / .well-known |
LTI 1.3 is more secure than 1.1 by design, but you can still mess it up:
iss matches a registered platform and aud contains your client_id. Don't just verify the signature — a valid JWT from the wrong platform is still an attack.Secure flag for SameSite=None.roles claim in the JWT tells you the user's LMS role, but your tool should maintain its own permissions. An LMS admin shouldn't automatically be an admin in your tool unless you've explicitly mapped that role.target_link_uri. In the login initiation (Step 1), verify that target_link_uri matches a URL your tool expects. Open redirects are a real risk if you blindly redirect to whatever the platform sends.Before going live with any LMS:
SameSite=None; Secure cookies (required for iframe embedding)/.well-known/jwks.jsoniss, client_id, message_type — never log the full JWT in production, it contains PII)LTI continues to evolve. [LTI Advantage](https://www.1edtech.org/standards/lti) bundles AGS, NRPS, and Deep Linking as the standard feature set. [LTI 1.3 Dynamic Registration](https://www.1edtech.org/standards/lti) is gaining adoption, which automates the platform registration process — no more manual config per institution.
For the backend architecture patterns I use in these integrations, [TypeScript 5.x Advanced Patterns](https://tostupidtooquit.com/blog/typescript-advanced-patterns) covers the branded types and discriminated unions that make LTI claim parsing type-safe. And if you're building an agent that ingests LMS data, [Agentic AI: Multi-Agent Systems](https://tostupidtooquit.com/blog/agentic-ai-multi-agent-systems) explores the orchestration patterns.
---
Compiling Rust to WebAssembly for real browser performance wins. Includes image filter benchmarks (grayscale, box blur), SIMD optimization, JS-Wasm boundary analysis, bundle size strategies, and Next.js integration.
Hardening Linux servers running GPU inference and training workloads. Covers SSH lockdown, Docker rootless mode, NVIDIA driver security, systemd sandboxing, audit logging, and network segmentation for AI infrastructure.