Understanding OIDC, OAuth, and LTI: The Authentication Stack Behind Learning Platforms
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:
- Fetch the auth server’s public keys from its JWKS (JSON Web Key Set) endpoint
- Verify the JWT signature using RS256
- Check
iss,aud,exp,nonce - 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:
| Concept | What It Is | Level |
|---|---|---|
| SSO | A goal — sign in once, access many apps | Concept |
| OIDC | A protocol for achieving SSO | Implementation |
| OAuth 2.0 | The foundation OIDC is built on | Framework |
| SAML | An older protocol also used for SSO | Alternative |
| JWT | A token format used by OIDC | Data 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#
- Single Sign-On — Student authenticates in LMS, launches into external tool with no second login
- Context — Tool knows which institution, course, role (student/instructor), and assignment
- 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.
| Aspect | Standard OIDC | LTI 1.3 |
|---|---|---|
| Who initiates? | User clicks login at the app | LMS initiates launch (third-party initiated) |
| Response type | code (authorization code) | id_token (implicit-like) |
| Response mode | Query parameter redirect | form_post (POST body) |
| Token exchange | App calls /token to get id_token | No token exchange — id_token arrives directly |
| Direction | App pulls token from provider | LMS 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_idaud= 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:
| Direction | Who Signs | Who Verifies | Purpose |
|---|---|---|---|
| LMS → Tool | LMS signs id_token with LMS private key | Tool fetches LMS JWKS to verify | Prove the launch is genuine |
| Tool → LMS | Tool signs client_assertion with tool private key | LMS fetches tool JWKS to verify | Prove 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:
- Tool publishes: JWKS URL, launch URL, redirect URIs, supported scopes
- LMS admin registers tool: Gets back a
client_id+deployment_id - LMS provides: Its own JWKS URL, authorization endpoint, token endpoint
- 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:
- Cognito redirects user to provider
/authorize - Provider redirects back to Cognito with
?code=AUTH_CODE - Cognito calls provider
/tokento exchange the code ← this step
LTI 1.3 uses response_mode=form_post:
- LMS POSTs directly to the tool with the
id_tokenin the request body - 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#
| Layer | Protocol | Solves |
|---|---|---|
| Authorization | OAuth 2.0 | “Can this app access this resource?” |
| Identity | OpenID Connect | “Who is this user?” |
| Ed-Tech Integration | LTI 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#
- RFC 6749 — The OAuth 2.0 Authorization Framework
- OpenID Connect Core 1.0
- OpenID Connect Discovery 1.0
- LTI 1.3 Specification (1EdTech)
- LTI 1.3 Security Framework (OIDC Launch)
- RFC 7519 — JSON Web Token (JWT)
- RFC 7517 — JSON Web Key (JWK)
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:
| What | OAuth 2.0 says |
|---|---|
| Token format | Could be JWT, opaque string, anything |
| Token validation | Introspection endpoint? Local verification? Your choice |
| Scope semantics | What does read:email mean? You decide |
| Client authentication | Secret, JWT assertion, mTLS, or none |
| Error response details | Minimal 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.