Verifying the launch JWT
The launch payload is a single launch_data field: a signed JWT (JWS) that carries the whole launch. It is signed with Voshi's system key, so you verify it against Voshi's public keys, published at the JWKS endpoint:
https://api.link.voshi.com/lti13/v1/jwks
(Also available at https://api.link.voshi.com/.well-known/jwks.)
Your API key is not involved in launch verification — it is only for the grades and management APIs.
What to check
| Check | Details |
|---|---|
| Algorithm | RS256 (RSA, 2048-bit). Pass an explicit algorithm allowlist to your JWT library. |
| Signature | Verify against the JWKS key whose kid matches the token's kid header (always present). |
| Expiry | exp and iat are in seconds. Treat an expired token as an invalid launch. Tokens are valid for 2 hours after iat. |
| Replay | The lifetime is bounded but not tight, so don't accept the same token twice — trade it for your own session on first use. |
Keys rotate (roughly monthly), and during rotation the JWKS contains more than one key. Fetch the JWKS dynamically and cache by kid — don't pin a single key. The JWKS clients below do this for you.
Code
- Python (PyJWT)
- Node (jose)
# pip install pyjwt cryptography
import jwt
from jwt import PyJWKClient
JWKS_URL = "https://api.link.voshi.com/lti13/v1/jwks"
jwks = PyJWKClient(JWKS_URL) # create once — it fetches and caches keys by kid
def read_launch_data(token: str) -> dict:
signing_key = jwks.get_signing_key_from_jwt(token)
# decode() verifies the signature and raises on an expired `exp`.
return jwt.decode(token, signing_key.key, algorithms=["RS256"])
// npm install jose
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS_URL = 'https://api.link.voshi.com/lti13/v1/jwks'
const jwks = createRemoteJWKSet(new URL(JWKS_URL)) // create once — caches keys by kid
async function readLaunchData(token) {
// jwtVerify checks the signature and throws on an expired `exp`.
const { payload } = await jwtVerify(token, jwks, { algorithms: ['RS256'] })
return payload
}
Both clients handle key rotation transparently: when a token arrives with a kid they haven't cached, they re-fetch the JWKS.
Common pitfalls
- Decoding without verifying. Most JWT libraries have a "decode only" mode (
jwt.decode(..., options={"verify_signature": False}),jwt.decodevsjwtVerify). Never use it onlaunch_data— an attacker can mint arbitrary claims in an unverified token. - Skipping the algorithm allowlist. Always pass
algorithms=["RS256"]explicitly so a manipulated header can't downgrade verification. - Pinning one key. A hardcoded public key works until the next rotation, then every launch fails. Use a JWKS client.
- Treating the token as a session. The token is valid for 2 hours and could be replayed within that window by anyone who obtains it. Exchange it for your own session cookie immediately, and reject reuse.
Once the token verifies, every claim inside it is trustworthy. On to the claims reference.