Skip to main content

Understanding OIDC, OAuth, and LTI: The Authentication Stack Behind Learning Platforms

June 2026 · 11 MIN · authentication · OIDC · OAuth · LTI

When I started working on integrating an external tool with Learning Management Systems, I quickly realized that the authentication landscape is a layered stack — each layer solving a specific problem, each building on the one below it.

TLDR:
#

OAuth 2.0 (authorization framework) → OpenID Connect (identity protocol) → LTI 1.3 (ed-tech integration protocol)

Each layer constrains the one below it and increases interoperability: OAuth 2.0 is deliberately flexible (a framework, not a protocol); OIDC locks down the choices into a concrete, interoperable protocol; LTI 1.3 further specializes OIDC for the education domain with form_post delivery, course context claims, and grade passback.

This post is my attempt to explain the full picture clearly — from the foundation up.

The Foundation: OAuth 2.0
#

OAuth 2.0 is not an authentication protocol. This is the most common misconception.

OAuth 2.0 is an authorization framework (see why “framework” matters). It answers one question: “Can this application access this resource on behalf of this user?” It says nothing about who the user is.

The Core Flow (Authorization Code Grant)
#

sequenceDiagram
    participant U as User (Browser)
    participant A as Application (Client)
    participant S as Auth Server (Google, etc)

    U->>A: 1. Click "Login"
    A->>U: 2. Redirect to auth server
    U->>S: 3. User logs in & consents
    S->>U: 4. Redirect back with authorization code
    U->>A: (code delivered via redirect)
    A->>S: 5. Exchange code for access_token
    S->>A: 6. Return access_token
    A->>S: 7. Call API with token

The key insight: after this flow, the application has an access token that lets it call APIs. But it still doesn’t know who the user is. The access token is a key to a resource, not an identity card.

What OAuth 2.0 Gives You
#

  • Access tokens — opaque strings that grant API access
  • Refresh tokens — long-lived tokens to get new access tokens
  • Scopes — fine-grained permissions (read:email, write:calendar)
  • Token expiration — short-lived access, renewable

What OAuth 2.0 Does NOT Give You
#

  • Who the user is (no identity)
  • Standard user profile format
  • Session management
  • Single sign-on

This is where OpenID Connect enters.


The Identity Layer: OpenID Connect (OIDC)
#

OpenID Connect is a thin identity layer on top of OAuth 2.0. It adds exactly one thing: a standardized way to know who the user is.

OIDC = OAuth 2.0 + ID Token + UserInfo
#

The difference is one additional token in the response: the ID Token.

OAuth 2.0 response:  { access_token: "abc123" }
OIDC response:       { access_token: "abc123", id_token: "eyJhbG..." }

The ID Token is a JWT (JSON Web Token) — a signed, base64-encoded JSON payload that contains identity claims:

{
  "iss": "https://accounts.google.com",
  "sub": "110248495921238986420",
  "aud": "my-app-client-id",
  "exp": 1717200000,
  "iat": 1717196400,
  "nonce": "random-value-for-replay-prevention",
  "email": "user@example.com",
  "name": "Jane Doe"
}

Why JWT Matters
#

The ID Token is self-contained and cryptographically signed. The application can verify it without calling back to the auth server:

  1. Fetch the auth server’s public keys from its JWKS (JSON Web Key Set) endpoint
  2. Verify the JWT signature using RS256
  3. Check iss, aud, exp, nonce
  4. Trust the claims inside

No network round-trip needed for verification. This is what makes OIDC scalable.

Discovery: The Well-Known Endpoint
#

Every OIDC provider publishes a discovery document at:

https://provider.com/.well-known/openid-configuration

This returns all the URLs you need:

{
  "issuer": "https://provider.com",
  "authorization_endpoint": "https://provider.com/authorize",
  "token_endpoint": "https://provider.com/token",
  "jwks_uri": "https://provider.com/.well-known/jwks.json",
  "userinfo_endpoint": "https://provider.com/userinfo",
  "response_types_supported": ["code", "id_token"],
  "scopes_supported": ["openid", "profile", "email"]
}

One URL gives you the entire integration surface. This is beautiful — but also the source of a compatibility problem we’ll revisit later.

The Standard OIDC Flow (Authorization Code)
#

1. App redirects user to provider /authorize?
     response_type=code&
     scope=openid profile email&
     client_id=xxx&
     redirect_uri=https://myapp.com/callback&
     state=csrf-token&
     nonce=replay-prevention

2. User authenticates at provider

3. Provider redirects back to app with ?code=AUTH_CODE&state=csrf-token

4. App exchanges code for tokens at /token endpoint:
     POST /token
     grant_type=authorization_code&
     code=AUTH_CODE&
     client_id=xxx&
     client_secret=yyy

5. Provider returns:
     { access_token: "...", id_token: "...", refresh_token: "..." }

6. App validates id_token JWT → now knows who the user is

This is the flow that AWS Cognito, Auth0, Okta, and every major identity platform implements.


The Relationship: OAuth vs OIDC vs SSO
#

These terms confuse everyone. Here’s the hierarchy:

ConceptWhat It IsLevel
SSOA goal — sign in once, access many appsConcept
OIDCA protocol for achieving SSOImplementation
OAuth 2.0The foundation OIDC is built onFramework
SAMLAn older protocol also used for SSOAlternative
JWTA token format used by OIDCData format

SSO is what you experience. OIDC is how it works. OAuth 2.0 is the plumbing underneath.


Enter the LMS: Learning Management Systems
#

A Learning Management System is the platform where courses, assignments, grades, and discussions live for an institution. Think Canvas, Moodle, Blackboard, or Open edX.

Students and instructors authenticate through the LMS using their institutional credentials — often via the school’s SSO (which may itself use OIDC or SAML under the hood). The LMS is their single pane of glass.

The problem arises when a course needs to use an external tool — like a coding playground, a simulation, or in our case, an AI app builder. The student shouldn’t need to:

  • Create a separate account
  • Remember a new password
  • Copy-paste URLs or access codes

This is where LTI comes in.


LTI 1.3: The Ed-Tech Integration Protocol
#

LTI (Learning Tools Interoperability) is a standard maintained by 1EdTech (formerly IMS Global) that lets external tools integrate with any compliant LMS through a single protocol.

LTI 1.3 (the current version, released 2019) is built on top of OIDC and OAuth 2.0 — but with critical differences.

What LTI 1.3 Handles
#

  1. Single Sign-On — Student authenticates in LMS, launches into external tool with no second login
  2. Context — Tool knows which institution, course, role (student/instructor), and assignment
  3. Grade Passback — Tool pushes scores back into the LMS gradebook (via AGS — Assignment and Grade Services)

How LTI 1.3 Differs from Standard OIDC
#

Here’s the critical point that tripped me up: LTI 1.3 uses OIDC, but not the standard Authorization Code flow.

AspectStandard OIDCLTI 1.3
Who initiates?User clicks login at the appLMS initiates launch (third-party initiated)
Response typecode (authorization code)id_token (implicit-like)
Response modeQuery parameter redirectform_post (POST body)
Token exchangeApp calls /token to get id_tokenNo token exchange — id_token arrives directly
DirectionApp pulls token from providerLMS pushes token to tool

The form_post response mode is the key difference. Instead of the user being redirected back with a ?code= query parameter (which the app then exchanges), the LMS renders an HTML form that auto-submits the id_token directly to the tool as a POST body.

This is more secure (tokens don’t appear in URL history or logs) but incompatible with identity providers that only support Authorization Code flow — like AWS Cognito.

The LTI 1.3 Launch Flow
#

sequenceDiagram
    participant S as Student (Browser)
    participant L as LMS (Canvas, etc)
    participant T as External Tool (PartyRock)

    S->>L: 1. Click assignment
    L->>T: 2. POST /oidc-login (iss, login_hint, target_link_uri, client_id)
    T->>S: 3. Redirect to LMS /authorize (state + nonce)
    S->>L: (follows redirect)
    L->>L: 4. Verify user is authenticated
    L->>S: 5. Render form_post with signed id_token
    S->>T: 6. Browser auto-POSTs id_token
    T->>T: 7. Validate JWT, create session
    T->>S: 8. Render app

Inside the LTI JWT
#

The id_token in step 6 carries rich context beyond just identity:

{
  "iss": "https://canvas.university.edu",
  "sub": "opaque-student-id-12345",
  "aud": "partyrock-client-id",
  "nonce": "abc123",
  "exp": 1717200000,

  "https://purl.imsglobal.org/spec/lti/claim/message_type":
    "LtiResourceLinkRequest",

  "https://purl.imsglobal.org/spec/lti/claim/roles": [
    "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"
  ],

  "https://purl.imsglobal.org/spec/lti/claim/context": {
    "id": "course-456",
    "title": "Introduction to AI"
  },

  "https://purl.imsglobal.org/spec/lti/claim/resource_link": {
    "id": "link-789",
    "title": "AI Challenge 1"
  },

  "https://purl.imsglobal.org/spec/lti/claim/custom": {
    "event_token": "pr-event-abc123"
  },

  "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": {
    "lineitems": "https://canvas.university.edu/api/lti/courses/456/line_items",
    "lineitem": "https://canvas.university.edu/api/lti/courses/456/line_items/789",
    "scope": [
      "https://purl.imsglobal.org/spec/lti-ags/scope/score"
    ]
  }
}

Notice: the AGS endpoint for grade passback is embedded directly in the launch token. The tool doesn’t need to discover it separately.


Grade Passback: AGS (Assignment and Grade Services)
#

Once the tool has completed work (student finishes a challenge), it needs to report a score back to the LMS gradebook. This is a server-to-server call using OAuth 2.0 client credentials:

Step 1: Get an Access Token
#

POST https://canvas.university.edu/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=eyJhbG...  (JWT signed with tool's private key)
&scope=https://purl.imsglobal.org/spec/lti-ags/scope/score

The client_assertion is a JWT where:

  • sub = the tool’s client_id
  • aud = the LMS token endpoint URL
  • Signed with the tool’s private RSA key

The LMS fetches the tool’s public key from its JWKS endpoint to verify. Trust flows in both directions.

Step 2: Post the Score
#

POST https://canvas.university.edu/api/lti/courses/456/line_items/789/scores
Authorization: Bearer <access_token>
Content-Type: application/vnd.ims.lis.v1.score+json

{
  "userId": "opaque-student-id-12345",
  "scoreGiven": 85,
  "scoreMaximum": 100,
  "activityProgress": "Completed",
  "gradingProgress": "FullyGraded",
  "timestamp": "2026-06-02T14:00:00Z",
  "comment": "Completed 3 of 4 challenges"
}

The student sees “85/100” appear in their Canvas gradebook. The LMS resolves the opaque userId to the real student internally — the external tool never learns the student’s real name or email.


Trust Model: Two JWKS Endpoints, Two Directions
#

The entire LTI 1.3 security model rests on asymmetric cryptography with two independent JWKS endpoints:

DirectionWho SignsWho VerifiesPurpose
LMS → ToolLMS signs id_token with LMS private keyTool fetches LMS JWKS to verifyProve the launch is genuine
Tool → LMSTool signs client_assertion with tool private keyLMS fetches tool JWKS to verifyProve the grade passback is genuine

Neither side shares private keys. Each publishes its public keys at a well-known JWKS URL. Keys can rotate independently — the standard pattern is to serve both old and new keys simultaneously for 24-48 hours during rotation.


Setup: One-Time Registration
#

Before any of this works, a one-time credential exchange happens:

  1. Tool publishes: JWKS URL, launch URL, redirect URIs, supported scopes
  2. LMS admin registers tool: Gets back a client_id + deployment_id
  3. LMS provides: Its own JWKS URL, authorization endpoint, token endpoint
  4. Both store each other’s configuration

Some LMS platforms support Dynamic Registration — the tool provides a single URL, the admin clicks it, and the LMS auto-configures everything. This eliminates manual steps.


Why This Matters: The Cognito Incompatibility
#

If you use AWS Cognito for authentication (as many AWS services do), you might think: “Can’t I just add the LMS as an external OIDC identity provider in Cognito?”

No. Here’s why:

Cognito’s OIDC federation expects Authorization Code Flow:

  1. Cognito redirects user to provider /authorize
  2. Provider redirects back to Cognito with ?code=AUTH_CODE
  3. Cognito calls provider /token to exchange the code ← this step

LTI 1.3 uses response_mode=form_post:

  1. LMS POSTs directly to the tool with the id_token in the request body
  2. There is no code exchange step

Cognito has no endpoint that can receive a POST body containing an id_token. It only knows how to pull tokens from a /token endpoint after receiving an authorization code.

This protocol mismatch means you must either:

  • Build a bridge Lambda that translates between the two (complex, fragile)
  • Validate the LTI JWT directly in your own code (simpler, industry-standard)

Most tools that integrate with LMS platforms choose the latter.


Summary
#

LayerProtocolSolves
AuthorizationOAuth 2.0“Can this app access this resource?”
IdentityOpenID Connect“Who is this user?”
Ed-Tech IntegrationLTI 1.3“Launch this student into this tool, in this course, with this assignment, and pass grades back”

Each builds on the previous. Understanding the layers makes the whole system legible — and makes it clear why shortcuts (like forcing LTI through Cognito) don’t work.


References
#


Appendix: Framework vs Protocol
#

Why do I keep calling OAuth 2.0 a “framework” and OIDC a “protocol”? This isn’t pedantry — it explains real-world compatibility pain.

RFC 6749 opens with:

“The OAuth 2.0 authorization framework enables a third-party application to obtain limited access to an HTTP service”

This word choice is deliberate. A protocol (like HTTP, SMTP, or OIDC) specifies exact message formats, required fields, and behaviors — two compliant implementations interoperate out of the box. A framework defines the building blocks and rules but leaves assembly decisions to implementors.

OAuth 2.0 intentionally leaves undefined:

WhatOAuth 2.0 says
Token formatCould be JWT, opaque string, anything
Token validationIntrospection endpoint? Local verification? Your choice
Scope semanticsWhat does read:email mean? You decide
Client authenticationSecret, JWT assertion, mTLS, or none
Error response detailsMinimal requirements, rest is up to you

The consequence: two “OAuth 2.0 compliant” authorization servers (say, AWS Cognito and Auth0) make different choices at every extension point. A client built for one won’t necessarily work with the other without changes.

OIDC constrains these choices into a concrete protocol:

  • Token format: must be JWT
  • Required claims: iss, sub, aud, exp, iat — mandatory
  • Discovery: must publish /.well-known/openid-configuration
  • Signature: must use RS256 (or specified alternatives)
  • UserInfo endpoint: defined shape and behavior

Two OIDC-compliant providers are interoperable — you can swap Google for Auth0 with minimal code changes because both follow the same rigid specification.

LTI 1.3 goes one step further, constraining OIDC into an even more specific protocol:

  • Response mode: must use form_post
  • Launch initiation: must use third-party initiated login
  • Claims: must include LTI-specific namespaced claims (roles, context, resource_link)
  • Grade passback: must use OAuth 2.0 client credentials with JWT bearer assertion

The arc of the stack is: flexibility → constraint → interoperability.

OAuth 2.0    →    OIDC    →    LTI 1.3
(framework)      (protocol)    (specialized protocol)
 flexible        rigid          ed-tech-specific
 choices open    choices locked  domain-locked

This is why “just use OAuth” isn’t enough for identity, and “just use OIDC” isn’t enough for LMS integration — each layer needs the constraints of the layer above to achieve real interoperability.

— RELATED —
PostgreSQL: UNIQUE INDEX vs INDEX — A Deep Dive