Auth integration
The seam
Section titled “The seam”Everything happens in your launch handler. It receives a verified LaunchResult; you:
- normalize claims with
ltiIdentity(), - find-or-create your user (your DB) — keyed on
sub, not email, - start a session with your auth library,
- set the session cookie iframe-safe and redirect.
import { launch, sessionRedirect } from '@ltikit/next'import { ltiIdentity } from '@ltikit/core'
export const POST = launch(lti, async (result) => { const id = ltiIdentity(result.claims) const user = await upsertUserByLtiSub(id) // your DB (Prisma/Drizzle/Supabase) const sessionValue = await createSession(user) // your auth lib's server API return sessionRedirect({ to: `${process.env.APP_URL}/home`, cookies: [{ name: 'session', value: sessionValue, maxAgeSec: 60 * 60 * 8 }], })})Three constraints (LTI-iframe realities, not per-library):
SameSite=None; Secure— cross-site LMS iframe; addPartitioned(CHIPS).sessionRedirecthandles it.- Set it in the launch response — the first request is a cross-site POST.
- Identify by
sub(+ issuer) —emailmay be absent (Canvas Test Student).
ltiIdentity(claims) → { sub, issuer, email?, name?, givenName?, familyName?, roles, isInstructor, isLearner, contextId?, contextTitle?, resourceLinkId? }.
Recipes
Section titled “Recipes”Either sign in via a Credentials provider with the verified sub (trusted — LTIkit already verified it), or
mint the Auth.js session JWT directly. Configure the session cookie for the iframe:
// auth configcookies: { sessionToken: { name: 'next-auth.session-token', options: { httpOnly: true, sameSite: 'none', secure: true, path: '/' }, },}A full working example (Next.js + Prisma + NextAuth) lives in
examples/next-prisma-demo.
Create a session with better-auth’s server API, then set its cookie iframe-safe.
export const POST = launch(lti, async (result) => { const id = ltiIdentity(result.claims) const user = await upsertUserByLtiSub(id) const { token } = await auth.api.createSession({ userId: user.id }) return sessionRedirect({ to: `${process.env.APP_URL}/home`, cookies: [{ name: 'better-auth.session_token', value: token, maxAgeSec: 60 * 60 * 24 * 7 }], })})Mint a magic link server-side and redirect through your confirm route to set SSR cookies. Key on the LTI
sub; synthesize an email when the LMS omits one.
export const POST = launch(lti, async (result) => { const id = ltiIdentity(result.claims) const email = id.email ?? `lti-${id.sub}@${new URL(id.issuer).host}.lti.local` const userId = await upsertProfileByLtiSub(id, email)
const { data } = await admin.auth.admin.generateLink({ type: 'magiclink', email }) const url = new URL(`${process.env.APP_URL}/auth/confirm`) url.searchParams.set('token_hash', data!.properties!.hashed_token) url.searchParams.set('type', 'magiclink') url.searchParams.set('next', '/home') return Response.redirect(url.toString(), 303)})Your /auth/confirm calls supabase.auth.verifyOtp({ token_hash, type }) and sets the SSR cookies — ensure
they’re SameSite=None; Secure. Mirrors the battle-tested TeachSim upsertLtiProfile pattern.
No framework — sign your own session token from the identity.
import { SignJWT } from 'jose'
export const POST = launch(lti, async (result) => { const id = ltiIdentity(result.claims) const user = await upsertUserByLtiSub(id) const secret = new TextEncoder().encode(process.env.SESSION_SECRET!) const jwt = await new SignJWT({ uid: user.id, roles: id.roles }) .setProtectedHeader({ alg: 'HS256' }) .setSubject(user.id) .setIssuedAt() .setExpirationTime('8h') .sign(secret)
return sessionRedirect({ to: `${process.env.APP_URL}/home`, cookies: [{ name: 'session', value: jwt, maxAgeSec: 60 * 60 * 8, partitioned: true }], })})Verify the JWT in your middleware on subsequent requests.
Gotchas
Section titled “Gotchas”- Cookie without
SameSite=None; Secure→ silently dropped in the iframe → user looks logged out. - Relying on
email→ breaks for no-email users. Key onsub. - Setting the session on a later page instead of the launch response → the cross-site POST has no session yet.
- Third-party cookie deprecation → add
Partitioned(CHIPS); the long-term fix is LTI Platform Storage (cookieless), on the roadmap.