The idea
When a launcher loads a learning app, it appends a query parameter to the URL: ?edu_session=<JWT>. That JWT is signed by the launcher's issuer, names the child, and is valid for five minutes. The app verifies the token against the issuer's public JWKS, reads the email out of it, drops its own session cookie, and redirects to strip the parameter.
That's the entire protocol. No client_id, no callback URLs, no refresh tokens, no PKCE, no OIDC discovery dance.
The flow
┌──────────┐ 1. mint(child, aud) ┌───────────────┐
│ Launcher │ ─────────────────────► │ Issuer │
│ │ ◄───────────────────── │ (signs JWT) │
└────┬─────┘ 2. edu_session JWT └───────────────┘
│
│ 3. GET https://your-app.com/?edu_session=<jwt>
│ (or the path your manifest declares via "entry")
▼
┌────────────┐ 4. verify(jwt, JWKS) ┌──────────────┐
│ Your app │ ──────────────────────► │ Public JWKS │
│ │ └──────────────┘
│ │ 5. setSessionCookie(email)
│ │ 6. 302 → same path without ?edu_session
└────────────┘
The integration, in full
Server-side, Node + jose:
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(
new URL("https://issuer.example-launcher.com/.well-known/jwks.json")
);
export async function eduSSO(req, res, next) {
const token = req.query.edu_session;
if (!token) return next();
try {
const { payload } = await jwtVerify(token, jwks, {
issuer: "https://issuer.example-launcher.com",
audience: "your-app-id",
clockTolerance: 5,
});
await upsertUser({ email: payload.email, name: payload.name });
await setSessionCookie(res, payload.email);
const clean = new URL(req.url, `https://${req.headers.host}`);
clean.searchParams.delete("edu_session");
return res.redirect(302, clean.pathname + clean.search);
} catch {
return next();
}
}
Mount it as middleware at the root of your app. That's the whole integration.
The token
{
"iss": "https://issuer.example-launcher.com",
"aud": "your-app-id",
"sub": "child:abc123",
"email": "student@example.com",
"email_verified": true,
"name": "Sam",
"iat": 1779150000,
"exp": 1779150300,
"jti": "01HX5XYV6FPK3R3D6T2H8E2VPR"
}
none.
Quickstart for AI coding agents
If you use Claude Code, Cursor, Codex, Aider, or similar, paste the prompt below into your agent. It is self-contained and tells the agent exactly what to implement and what to skip. You only need ISSUER and AUDIENCE from your launcher operator.
Implement EduSSO v1 (tutor / relying party).
Specs (fetch before coding):
- https://raw.githubusercontent.com/open-learn-org/open-learn-protocol/main/specs/edu-sso/tutor.md
- https://raw.githubusercontent.com/open-learn-org/open-learn-protocol/main/specs/edu-sso/discovery.md
- https://raw.githubusercontent.com/open-learn-org/open-learn-protocol/main/specs/edu-sso/conformance.md
Reference: https://github.com/open-learn-org/open-learn-protocol/tree/main/specs/edu-sso/examples/example-tutor
Read EDU_SSO_ISSUER and EDU_SSO_AUDIENCE from env (fail fast if missing).
For multi-launcher, accept EDU_SSO_LAUNCHERS as a JSON array of {issuer, audience}.
On every request, when ?edu_session=<jwt> is present:
- Verify via the issuer JWKS at ${ISSUER}/.well-known/jwks.json (jose
createRemoteJWKSet / PyJWKClient — they cache for 1h).
- Allow only RS256 and EdDSA. Reject alg=none and HS*.
- Check iss matches exactly, aud matches exactly, exp+iat with ≤5s skew,
email_verified === true.
- Multi-launcher: dispatch by the token's iss; drop unknown issuers silently.
- On success: upsert by email, set your session cookie, 302 to the same
path with edu_session stripped.
- On any failure: drop the token silently and fall through to normal flow.
Never log the raw token; redact edu_session from URL logs.
Publish /.well-known/edu-sso.json:
{ "version": 1, "audience": "<EDU_SSO_AUDIENCE>",
"issuers": [...], "entry": "/auth/edu-sso" } (entry optional, same-origin)
Read fields from env so the manifest can't drift from your config.
Do NOT implement (even if the framework makes them easy):
- OAuth code exchange, refresh tokens, PKCE, state/nonce.
- Scopes, dynamic client registration, federated logout.
- jti replay tracking. Symmetric JWT verification.
Verify with https://validator.openlearnprotocol.org — six checks, must
all pass. Add https://test-issuer.openlearnprotocol.org to your trust
list in staging only (its private key is public).
When done: show the diff, which conformance items you verified, and
anything you couldn't verify.
Append one of these hints so the agent doesn't guess your stack:
- Next.js (App Router)Implement as
middleware.tsat the project root. Usejose. Set the cookie viaNextResponse.cookies.setand 302 with the stripped URL. - Next.js (Pages Router)Implement in
pages/_middleware.tsor ingetServerSidePropson the entry page. - ExpressMount before the session middleware.
jose+createRemoteJWKSet. - FastAPI / Starlette
HTTPMiddlewarebefore auth dependencies.PyJWTwithPyJWKClient. - Go (net/http)Wrap the mux.
github.com/golang-jwt/jwt/v5+github.com/MicahParks/keyfunc/v3.
Expected diff size: ~50 lines. The reference Node implementation is that size. If your agent writes substantially more, ask why.
Read the spec
- Overview & FAQ specs/edu-sso/protocol.md
- Issuer implementation spec specs/edu-sso/issuer.md
- Relying-party implementation spec specs/edu-sso/tutor.md
- Conformance checklist specs/edu-sso/conformance.md
-
Discovery manifest (optional)
specs/edu-sso/discovery.md — includes the
entryfield for dedicated SSO routes - Reference implementations specs/edu-sso/examples/ — school-issuer, example-tutor, school-host
- Conformance validator validator.openlearnprotocol.org — point it at your tutor and run six end-to-end checks
