LTI integration often breaks due to version mismatches, session loss, or misconfigured JWKS. Our engineers solve these issues end-to-end—from auditing your current LMS to connecting any LTI-compliant tool. We have accumulated a library of typical errors and ready templates, significantly shortening development time.
According to the IMS Global LTI 1.3 specification, using OAuth 2.0 and JWKS provides a higher security level compared to LTI 1.1.
Why LTI 1.3 is Better Than LTI 1.1?
LTI 1.1 uses OAuth 1.0—an outdated protocol with fragile signatures. LTI 1.3 is based on OAuth 2.0 and OpenID Connect: more secure, transparent, and easier to debug. JWT tokens with JWKS keys prevent request forgery. Many vendors (Coursera, Zoom, Adobe) are moving to LTI 1.3—without it, you cannot connect modern tools.
| Feature | LTI 1.1 | LTI 1.3 |
|---|---|---|
| Protocol | OAuth 1.0 | OAuth 2.0 + OIDC |
| Signature | HMAC-SHA1 | RSA/JWT |
| Key management | Shared secret (static) | JWKS (rotation) |
| Security | Low | High |
| Integration time | 2-3 days | 3-5 days |
How We Configure LTI 1.3 in Your LMS
The LMS acts as the Platform (per IMS). We initialize the process using OIDC Login Request and generate JWT. Below is typical code with the ltijs library:
import { Provider } from 'ltijs'; // npm install ltijs
// Configure LMS as LTI Platform
Provider.setup(
process.env.LTI_ENCRYPTION_KEY!, // 32+ characters for cookie encryption
{
url: process.env.DATABASE_URL!,
plugin: require('ltijs-postgresql'), // adapter for PostgreSQL
},
{
cookies: { secure: true, sameSite: 'None' },
devMode: process.env.NODE_ENV !== 'production',
}
);
// Register external tool
await Provider.registerPlatform({
url: 'https://tool.example.com',
name: 'Kahoot Integration',
clientId: process.env.KAHOOT_CLIENT_ID!,
authenticationEndpoint: 'https://tool.example.com/lti/auth',
accesstokenEndpoint: 'https://tool.example.com/lti/token',
authConfig: {
method: 'JWK_SET',
key: 'https://tool.example.com/.well-known/jwks.json',
},
});
// Tool launch handler
Provider.onConnect(async (token, req, res) => {
const { email, name, role } = token.userInfo;
const contextId = token.platformContext.context?.id;
// Verify/create user in external tool
res.json({ token: token.jwt });
});
await Provider.deploy({ serverless: true });
LTI 1.1—for Legacy Tools
Some vendors (Phet, certain tests) still use LTI 1.1 with OAuth 1.0 signature:
import oauth from 'oauth-signature';
function launchLti11(
launchUrl: string,
consumerKey: string,
consumerSecret: string,
params: Record<string, string>
): { url: string; method: 'POST'; body: string } {
const baseParams: Record<string, string> = {
lti_message_type: 'basic-lti-launch-request',
lti_version: 'LTI-1p0',
oauth_callback: 'about:blank',
oauth_consumer_key: consumerKey,
oauth_nonce: crypto.randomUUID().replace(/-/g, ''),
oauth_signature_method: 'HMAC-SHA1',
oauth_timestamp: String(Math.floor(Date.now() / 1000)),
oauth_version: '1.0',
...params,
};
const signature = oauth.generate('POST', launchUrl, baseParams, consumerSecret, '');
baseParams.oauth_signature = signature;
const body = new URLSearchParams(baseParams).toString();
return { url: launchUrl, method: 'POST', body };
}
// LTI tool launch page
app.get('/courses/:courseId/tools/:toolId/launch', authenticate, async (req, res) => {
const tool = await db.ltiTools.findById(req.params.toolId);
const enrollment = await db.enrollments.findByCourseAndUser(
req.params.courseId, req.user.id
);
if (tool.version === '1.1') {
const launch = launchLti11(tool.launch_url, tool.consumer_key, tool.consumer_secret, {
resource_link_id: `${req.params.courseId}-${req.params.toolId}`,
resource_link_title: tool.name,
user_id: req.user.id,
lis_person_name_full: req.user.name,
lis_person_contact_email_primary: req.user.email,
roles: enrollment.role === 'instructor' ? 'Instructor' : 'Student',
context_id: req.params.courseId,
context_title: enrollment.courseTitle,
});
// Auto-submit form via HTML
res.send(`
<!DOCTYPE html>
<html>
<body>
<form id="lti" method="POST" action="${launch.url}">
${Object.entries(Object.fromEntries(new URLSearchParams(launch.body)))
.map(([k, v]) => `<input type="hidden" name="${k}" value="${v}" />`)
.join('\n')}
</form>
<script>document.getElementById('lti').submit();</script>
</body>
</html>
`);
}
});
Storing Tool Configuration
CREATE TABLE lti_tools (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_id UUID REFERENCES courses(id),
name VARCHAR(255) NOT NULL,
version VARCHAR(10) NOT NULL, -- '1.1' | '1.3'
-- LTI 1.1
launch_url TEXT,
consumer_key VARCHAR(255),
consumer_secret TEXT,
-- LTI 1.3
client_id VARCHAR(255),
platform_id VARCHAR(255),
deployment_id VARCHAR(255),
oidc_auth_url TEXT,
jwks_url TEXT,
-- Settings
open_in_new_tab BOOLEAN DEFAULT false,
custom_params JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now()
);
Getting Results via LTI Outcomes (1.1)
// Tool can send score back to LMS
app.post('/lti/grade', async (req, res) => {
const { sourcedId, score, action } = parseLtiOutcomesXml(req.body);
// sourcedId contains userId and resourceLinkId
const [userId, resourceId] = parseLisResultSourcedId(sourcedId);
await db.ltiGrades.upsert({
userId,
resourceId,
score: Number(score),
receivedAt: new Date(),
});
// Update course progress
await updateCourseProgress(userId, resourceId, Number(score));
res.type('application/xml').send(`
<?xml version="1.0" encoding="UTF-8"?>
<imsx_POXEnvelopeResponse xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
<imsx_POXHeader>
<imsx_POXResponseHeaderInfo>
<imsx_version>V1.0</imsx_version>
<imsx_messageIdentifier>${crypto.randomUUID()}</imsx_messageIdentifier>
<imsx_statusInfo>
<imsx_codeMajor>success</imsx_codeMajor>
<imsx_severity>status</imsx_severity>
</imsx_statusInfo>
</imsx_POXResponseHeaderInfo>
</imsx_POXHeader>
<imsx_POXBody><replaceResultResponse /></imsx_POXBody>
</imsx_POXEnvelopeResponse>
`);
});
What's Included in LTI Integration Work?
- Audit of current LMS architecture: identifying bottlenecks (N+1 queries, session loss), evaluating compatibility with required tools.
- Designing storage schema for tool configurations (lti_tools table with support for both LTI versions).
- Implementing LTI 1.1 and/or LTI 1.3 launch flow on your stack (Node.js, Python, PHP, Ruby—any).
- Integration with external tool: registration, test launch, grade passback (Outcomes).
- Writing documentation for administrators and instructors on adding new tools.
- Training your team: how to debug errors, update keys, work with JWKS.
- 30-day post-release support: bug fixes, consultations.
Common Mistakes in LTI Integration
- User type mismatch: confusing Instructor and Student roles in JWT.
- JWKS errors: keys expire or are not updated.
- CORS issues: external tools block requests.
- Incorrect OIDC Login Request configuration: wrong redirect URI.
Timelines and Cost
LTI 1.1 integration (Consumer + Launch + Grades) — 3–5 days. LTI 1.3 with OIDC flow — 1 week. Support for both versions plus tool management UI — 2 weeks. Cost is determined individually, and using ready templates can significantly reduce the budget.
Our Experience and Guarantees
Case: integrating Kahoot into a university LMS. It required LTI 1.3 support, as Kahoot had moved to the new standard. We implemented the OIDC flow, configured JWKS, and tested grade passback. As a result, students got a unified interface without repeated authentication. Get a consultation on LTI integration today. Contact us – we will assess your task and offer a solution within 2 business days.







