Skip to main content
← writing

Implementing LTI 1.3 OIDC Flow in TypeScript

6 min read LTILMSTypeScriptOIDCSecurity

LTI 1.3 replaced OAuth 1.0 with OpenID Connection and JWT-based authentication. If you're building a Tool Provider, this guide walks through the complete implementation in TypeScript.

LTI 1.3 replaced OAuth 1.0 with OpenID Connect and JWT-based authentication. If you're building a Tool Provider (the application being launched from an LMS), this guide walks through the complete implementation in TypeScript.

The key is understanding that LTI 1.3 is primarily a security framework. Most of your code is validating tokens, checking signatures, and preventing attacks. Once you've verified everything, you get a rich set of claims about the user, their role, and the context of the launch.

Understanding the Flow

The LTI 1.3 launch happens in three steps:

  1. Login Initiation - The LMS sends a request to your login endpoint
  2. Authentication Request - You redirect to the LMS's authentication endpoint
  3. Launch Validation - The LMS posts back with an id_token containing launch data

Step 1: Platform Registration

Every LMS instance that will launch your tool needs to be registered with it's unique configuration in your database. The process is:

  1. LMS Admin registers your tool
  2. LMS Admin provides configuration details
  3. You store the configuration details in your DB

The Registration Process

A. LMS Admin Registers Your Tool

The admin goes into their LMS and creates an "LTI 1.3 Tool" configuration. They provide:

  • Login initiation URL: https://yourtool.com/lti/login
  • Redirect/launch URL: https://yourtool.com/lti/launch
  • Your public JWKS URL: https://yourtool.com/lti/jwks

B. LMS Provides Configuration Details

After registration, the LMS displays:

  • Client ID (e.g., 125900000000000079)
  • Deployment ID(s) (e.g., 1:dGVzdGRlcGxveW1lbnQ)
  • Authentication endpoint
  • JWKS URL
  • Issuer identifier

C. You Store This in Your Database

Take those values and create a platform configuration entry.

// University of Hoopla's Canvas
const hooplaCanvas: PlatformRegistration = {
  platformId: "https://canvas.instructure.com", // generic issuer
  clientId: "125900000000000079",
  authenticationEndpoint: "https://hoopla.instructure.com/api/lti/authorize_redirect",
  jwksUrl: "https://hoopla.instructure.com/api/lti/security/jwks",
  deploymentIds: ["1:dGVzdGRlcGxveW1lbnQ"]
};

Why Store Platform Configurations?

Security validation - When a launch request arrives, you verify the issuer is known and the client_id matches.

Token verification - You need the JWKS URL to fetch public keys for signature verification.

Deployment authorization - Verify launches come from approved deployments.

Multi-tenancy support - Different platforms may have different feature configurations.

Database Schema

Store platforms in a proper database:

interface PlatformRegistration {
  id: string;
  platformId: string; // The issuer URL
  platformName: string; // "University A Canvas"
  clientId: string;
  authenticationEndpoint: string;
  jwksUrl: string;
  deploymentIds: string[];
  createdAt: Date;
  updatedAt: Date;
  isActive: boolean;
}

Build an admin interface where you can add new platform registrations, edit configurations, and test connections.

Step 2: Login Initiation

When a user clicks your tool link in their LMS, the platform sends a request to your login endpoint.

What the LMS Sends

// POST https://yourtool.com/lti/login

{
  "iss": "https://canvas.instructure.com",
  "login_hint": "185d32f8-...",  // Opaque - don't parse
  "target_link_uri": "https://yourtool.com/lti/launch",
  "lti_message_hint": "eyJ0eXAi...",  // Optional, also opaque
  "client_id": "125900000000000079",
  "lti_deployment_id": "1:dGVzdGRlcGxveW1lbnQ"
}

Critical parameters:

  • iss - Platform identifier, used to lookup configuration
  • login_hint - Opaque string the platform uses to identify the user (pass through unchanged)
  • target_link_uri - Where to send the user after authentication
  • lti_message_hint - Optional opaque data (pass through unchanged if present)
  • client_id - Your tool's identifier (verify it matches your config)
  • lti_deployment_id - Identifies which deployment is launching

What "Opaque" Means

Opaque = "Don't look inside, just pass it along"

Think of login_hint and lti_message_hint like sealed envelopes. They mean something to the platform that created them, but you just hold onto them and pass them back unchanged.

// Just pass it through
const authParams = new URLSearchParams({
  login_hint: login_hint  // Unchanged
});

Implementation

import crypto from 'crypto';

class LTI13LoginHandler {
  async handleLoginInitiation(req: Request, res: Response): Promise<void> {
    const {
      iss,
      login_hint,
      target_link_uri,
      lti_message_hint,
      client_id,
      lti_deployment_id
    } = req.body;

    // Validate required parameters
    if (!iss || !login_hint || !target_link_uri) {
      res.status(400).json({ error: 'Missing required parameters' });
      return;
    }

    // Look up platform configuration
    const platform = this.platformConfigs[iss];
    if (!platform) {
      res.status(400).json({ error: 'Unknown platform' });
      return;
    }

    // Verify client_id and deployment_id
    if (platform.clientId !== client_id) {
      res.status(403).json({ error: 'Invalid client_id' });
      return;
    }

    if (!platform.deploymentIds.includes(lti_deployment_id)) {
      res.status(403).json({ error: 'Unauthorized deployment' });
      return;
    }

    // Generate security tokens
    const state = crypto.randomBytes(32).toString('hex');
    const nonce = crypto.randomBytes(32).toString('hex');

    // Store for later verification (use Redis, etc.)
    await this.sessionStore.set(
      `lti_state_${state}`,
      { nonce, targetLinkUri: target_link_uri, issuer: iss },
      600 // 10 minute TTL
    );

    // Build authentication request
    const authParams = new URLSearchParams({
      response_type: 'id_token',
      response_mode: 'form_post',
      scope: 'openid',
      client_id: platform.clientId,
      redirect_uri: target_link_uri,
      login_hint,
      state,
      nonce,
      prompt: 'none'
    });

    // Add lti_message_hint if present
    if (lti_message_hint) {
      authParams.append('lti_message_hint', lti_message_hint);
    }

    // Redirect to platform's auth endpoint
    const authUrl = `${platform.authenticationEndpoint}?${authParams.toString()}`;
    res.redirect(authUrl);
  }
}

Security Tokens Explained

State token - CSRF protection. You generate it, send it to the platform, and verify it matches when you get the id_token back.

Nonce token - Replay attack protection. Generated randomly, included in the signed id_token, and verified to ensure the token hasn't been used before.

Both should be cryptographically random 32-byte values.

Session Storage

Your session store needs to:

  • Support TTL (keep it short: 5-10 minutes)
  • Be fast (every launch hits this)
  • Work across load-balanced servers
// Example with Redis
const sessionStore = {
  async set(key: string, data: any, ttlSeconds: number) {
    await redisClient.setEx(key, ttlSeconds, JSON.stringify(data));
  },
  
  async get(key: string) {
    const data = await redisClient.get(key);
    return data ? JSON.parse(data) : null;
  }
};

Step 3: Launch Validation

After authentication, the platform POSTs back with an id_token containing all the launch data.

Install Dependencies

npm install jsonwebtoken jwks-rsa

Token Structure

interface LTI13LaunchToken {
  iss: string; // Platform issuer
  aud: string | string[]; // Your client ID
  sub: string; // User identifier
  exp: number;
  iat: number;
  nonce: string;
  'https://purl.imsglobal.org/spec/lti/claim/deployment_id': string;
  'https://purl.imsglobal.org/spec/lti/claim/message_type': string;
  'https://purl.imsglobal.org/spec/lti/claim/version': string;
  'https://purl.imsglobal.org/spec/lti/claim/resource_link': {
    id: string;
    title?: string;
  };
  'https://purl.imsglobal.org/spec/lti/claim/roles': string[];
  'https://purl.imsglobal.org/spec/lti/claim/context'?: {
    id: string;
    title?: string;
  };
}

Implementation

import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

class LTI13LaunchValidator {
  private jwksClients: Map<string, jwksClient.JwksClient>;

  constructor(private platformConfigs: PlatformConfig) {
    this.jwksClients = new Map();
    
    // Initialize JWKS clients for each platform
    Object.values(platformConfigs).forEach(config => {
      this.jwksClients.set(
        config.platformId,
        jwksClient({
          jwksUri: config.jwksUrl,
          cache: true,
          cacheMaxAge: 3600000,
          rateLimit: true
        })
      );
    });
  }

  private async getPublicKey(
    platformId: string,
    header: jwt.JwtHeader
  ): Promise<string> {
    const client = this.jwksClients.get(platformId);
    
    return new Promise((resolve, reject) => {
      client.getSigningKey(header.kid, (err, key) => {
        if (err) reject(err);
        else resolve(key.getPublicKey());
      });
    });
  }

  async validateLaunch(req: Request, res: Response): Promise<void> {
    const { id_token, state } = req.body;

    if (!id_token || !state) {
      res.status(400).json({ error: 'Missing id_token or state' });
      return;
    }

    // Retrieve stored state data
    const stateData = await this.sessionStore.get(`lti_state_${state}`);
    if (!stateData) {
      res.status(400).json({ error: 'Invalid or expired state' });
      return;
    }

    // Decode token to get header and payload
    const decoded = jwt.decode(id_token, { complete: true });
    if (!decoded) {
      res.status(400).json({ error: 'Invalid token format' });
      return;
    }

    const { header, payload } = decoded;
    const claims = payload as LTI13LaunchToken;

    // Validate issuer
    const platform = this.platformConfigs[claims.iss];
    if (!platform) {
      res.status(400).json({ error: 'Unknown issuer' });
      return;
    }

    // Get platform's public key and verify signature
    const publicKey = await this.getPublicKey(claims.iss, header);
    
    const verified = jwt.verify(id_token, publicKey, {
      algorithms: ['RS256'],
      audience: platform.clientId,
      issuer: platform.platformId,
      clockTolerance: 30
    }) as LTI13LaunchToken;

    // Validate nonce
    if (verified.nonce !== stateData.nonce) {
      res.status(400).json({ error: 'Invalid nonce' });
      return;
    }

    // Validate deployment ID
    const deploymentId = verified['https://purl.imsglobal.org/spec/lti/claim/deployment_id'];
    if (!platform.deploymentIds.includes(deploymentId)) {
      res.status(400).json({ error: 'Invalid deployment_id' });
      return;
    }

    // Validate message type and version
    const messageType = verified['https://purl.imsglobal.org/spec/lti/claim/message_type'];
    if (messageType !== 'LtiResourceLinkRequest') {
      res.status(400).json({ error: 'Unsupported message type' });
      return;
    }

    const version = verified['https://purl.imsglobal.org/spec/lti/claim/version'];
    if (version !== '1.3.0') {
      res.status(400).json({ error: 'Unsupported LTI version' });
      return;
    }

    // Process successful launch
    await this.processLaunch(verified, res);
  }

  private async processLaunch(
    token: LTI13LaunchToken,
    res: Response
  ): Promise<void> {
    const userId = token.sub;
    const roles = token['https://purl.imsglobal.org/spec/lti/claim/roles'];
    const context = token['https://purl.imsglobal.org/spec/lti/claim/context'];
    
    // Create session, store launch data, render your app
    res.send(`
      <html>
        <body>
          <h1>Launch Successful</h1>
          <p>User: ${userId}</p>
          <p>Context: ${context?.title || 'N/A'}</p>
        </body>
      </html>
    `);
  }
}

Express Route Setup

import express from 'express';

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

const loginHandler = new LTI13LoginHandler(platformConfigs, sessionStore);
const launchValidator = new LTI13LaunchValidator(platformConfigs, sessionStore);

// Support both GET and POST for login
app.all('/lti/login', (req, res) => {
  loginHandler.handleLoginInitiation(req, res);
});

// Launch endpoint (always POST)
app.post('/lti/launch', (req, res) => {
  launchValidator.validateLaunch(req, res);
});

app.listen(3000, () => {
  console.log('LTI Tool running on port 3000');
});

Security Best Practices

Always validate nonce - Prevents replay attacks

Check token expiration - Tokens should have short lifespans (5-10 minutes)

Verify deployment_id - Ensures launches come from approved deployments

Use HTTPS only - Never accept LTI launches over HTTP

Implement CSRF protection - State parameter serves this purpose

Secure session storage - Use Redis with encryption for production

Cache JWKS responses - Improves performance significantly

Rate limit endpoints - Prevent abuse of login and launch endpoints

Log security events - Track failed validations and suspicious activity

Common Pitfalls

Trying to parse login_hint - It's opaque, treat it as a black box and pass it through unchanged

Not passing through lti_message_hint - If the platform sends it, you must include it in the auth request

Weak state/nonce generation - Use cryptographically secure random values, not Date.now() or Math.random()

Long session TTL - Keep it short (5-10 minutes), users should complete the flow quickly

Forgetting clock skew - Use clockTolerance in JWT verification to handle server time differences

Not caching JWKS - Fetching keys on every launch creates unnecessary latency


← all writing