instructure/canvas-lms · error
invalid_grant
Error message
invalid_grant
What it means
Not a thrown exception but an HTTP 400 response body: the Canvas LTI 1.3 /ims/authorize endpoint catches a set of authentication/grant failures (JSON::JWS::UnexpectedAlgorithm, InvalidAuthJwt, SecretNotFound, MissingAuthorizationCode, InvalidGrant) in one rescue_from and renders {"error":"invalid_grant"}. It means the OIDC authorization-code grant presented by the tool could not be validated: the JWT could not be verified, the client secret/tool was not found, or the authorization code is missing, unknown, expired, or already redeemed. The actual cause is logged server-side via Lti::Errors::ErrorLogger, so the JSON body alone is intentionally opaque.
Solutions
- Check server logs / ErrorReports for the Lti::Errors::ErrorLogger entry — it names the specific rescued exception (e.g. InvalidAuthJwt vs SecretNotFound) and pinpoints the real cause.
- Verify the tool's client_id matches an active Canvas developer key with the correct redirect URIs and the tool's public JWK registered under the right algorithm.
- Re-run the OIDC launch/login flow from the start to obtain a fresh authorization code; never replay or cache codes.
- Confirm the authorization server and tool clocks are NTP-synced and the JWT exp/iat/nbf fall within Canvas's allowed skew.
Example fix
// before: tool signs client_assertion with HS256
const assertion = jwt.sign(payload, clientSecret, { algorithm: 'HS256' });
// after: use the asymmetric algorithm registered on the developer key
const assertion = jwt.sign(payload, privateKey, {
algorithm: 'RS256',
keyid: kid,
expiresIn: '5m'
}); Defensive patterns
Strategy: try-catch
Validate before calling
// before calling /ims/authorize
const payload = { iss: clientId, sub: clientId, aud: authUrl, exp: Math.floor(Date.now()/1000) + 300, iat: Math.floor(Date.now()/1000) };
if (!code || Date.now() - codeIssuedAt > 60_000) throw new Error('authorization code missing or possibly expired — restart OIDC flow');
if (!devKeyActive) throw new Error('developer key missing or inactive'); Type guard
function isInvalidGrantResponse(res) {
return res.status === 400 && typeof res.body === 'object' && res.body !== null && res.body.error === 'invalid_grant';
} Try / catch
try {
const res = await fetch(authorizeUrl, { method: 'POST', body: assertionBody });
if (!res.ok) throw Object.assign(new Error('token request failed'), { status: res.status, body: await res.json() });
} catch (e) {
if (isInvalidGrantResponse(e)) {
logServerSideHint(e.body); // body is opaque; check Canvas ErrorReports
restartOidcLoginFlow(); // fresh authorization code
} else {
throw e;
}
} Prevention
- Always fetch a fresh authorization code immediately before exchanging it; never reuse or cache codes.
- Keep tool clocks NTP-synced; set short exp (≤5 min) on client_assertion JWTs.
- Assert the developer key is active and its algorithm/JWK set matches the signing key before launches.
- Monitor Canvas ErrorReports (server side) since the 400 body deliberately hides the specific cause.
When it happens
Trigger: POST to /api/lti/ims/authorize (or the account-scoped variant) where: the client_assertion JWT is signed with an unexpected/unsupported algorithm; the JWT is malformed, expired, or its iss/aud do not match; no Canvas developer key matches the client_id (SecretNotFound); grant_type is not authorization_code; or the supplied code is absent, expired, or already used.
Common situations: Tool platform configured with a stale or deleted Canvas developer key; client_assertion signed with RS256 but key configured for another algorithm (or vice versa); clock skew making the JWT exp/iat invalid; tool reusing a one-time authorization code after a redirect replay or double POST; missing 'code' parameter because the login flow failed silently upstream.
Related errors
- the Developer Key is not active or available in this…
- Access token expired
- Access token invalid - signature likely incorrect
- Developer key mismatch
- e.message
AI-assisted analysis of instructure/canvas-lms@1c9f0bb801 (2026-09-15).
Data as JSON: /api/errors/66824df9b778746e.
Report an issue: GitHub.
Appendix: source
Thrown at app/controllers/lti/ims/authorization_controller.rb:86
format: ["application/json"].freeze,
action: ["POST"].freeze
}.freeze
].freeze
class InvalidGrant < RuntimeError; end
JWT_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
AUTHORIZATION_CODE_GRANT_TYPE = "authorization_code"
GRANT_TYPES = [JWT_GRANT_TYPE, AUTHORIZATION_CODE_GRANT_TYPE].freeze
rescue_from JSON::JWS::VerificationFailed,
JSON::JWT::InvalidFormat,
JSON::JWS::UnexpectedAlgorithm,
Lti::OAuth2::AuthorizationValidator::InvalidAuthJwt,
Lti::OAuth2::AuthorizationValidator::SecretNotFound,
Lti::OAuth2::AuthorizationValidator::MissingAuthorizationCode,
InvalidGrant do |e|
Lti::Errors::ErrorLogger.log_error(e)
render json: { error: "invalid_grant" }, status: :bad_request
end
# @API authorize
#
# Returns an access token that can be used to access other LTI services
#
# @argument grant_type [Required, String]
# When using registration provided credentials it should contain the exact value of:
# "urn:ietf:params:oauth:grant-type:jwt-bearer" once a tool proxy is created
# When using developer credentials it should have the value of: "authorization_code" and pass
# the optional argument `code` defined below
#
# @argument code [optional, String]
# Only used in conjunction with a grant type of "authorization_code". Should contain the "reg_key" from the
# registration message
#
# @argument assertion [Required, AuthorizationJWT]
# The AuthorizationJWT here should be the JWT in a string format
#View on GitHub (pinned to 1c9f0bb801)