instructure/canvas-lms · error · InvalidAuthJwt
#
Error message
#{validator.error_message} What it means
Lti::OAuth2::AuthorizationValidator#jwt decodes the posted OIDC/authorization-request JWT with the tool's shared secret, then runs it through Canvas::Security::JwtValidator with the expected audience set to the authorization URL. If validation fails (wrong aud, missing/expired claims, etc.), InvalidAuthJwt is raised with the validator's error_message as the message.
Solutions
- Read validator.error_message in the raised exception to see the exact failing claim and fix it in the tool's JWT
- Set the JWT aud to the Canvas OIDC authorization endpoint URL configured for the tool
- Ensure required claims (iss, sub, iat, exp, aud) are present and temporally valid
- Verify the tool platform config points at the correct Canvas instance/authorization URL
Example fix
// before
const payload = {iss: clientId, aud: 'https://tool.example.com/callback', sub: clientId, iat, exp};
// after
const payload = {iss: clientId, aud: 'https://canvas.example.com/api/lti/authorize_redirect', sub: clientId, iat, exp}; Defensive patterns
Strategy: validation
Validate before calling
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString());
assert(payload.aud === authorizationUrl, 'aud must be the Canvas authorization URL');
assert(payload.iss && payload.sub && payload.iat && payload.exp, 'required claims missing');
assert(payload.exp > Date.now()/1000, 'jwt expired'); Type guard
function authJwtLooksValid(jwt, authorizationUrl) {
try {
const p = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString());
const audOk = Array.isArray(p.aud) ? p.aud.includes(authorizationUrl) : p.aud === authorizationUrl;
return audOk && !!p.iss && !!p.sub && p.exp > Date.now() / 1000;
} catch { return false; }
} Try / catch
begin
validator.jwt
rescue Lti::OAuth2::AuthorizationValidator::InvalidAuthJwt => e
Rails.logger.warn("auth jwt invalid: #{e.message}")
render json: {error: 'invalid_request'}, status: :bad_request
end Prevention
- Point tools at the correct Canvas OIDC authorization URL and use it as aud
- Include iss, sub, iat, exp in every authorization request JWT
- Re-fetch platform OpenID configuration when Canvas domains change
- Log validator.error_message server-side to pinpoint failing claims
When it happens
Trigger: Third-party tool POSTing an authorization-request (OIDC launch) JWT whose claims fail JwtValidator checks - typically aud not matching the Canvas authorization_url, missing sub/iat/exp, or stale iat.
Common situations: Tools configured with the wrong Canvas login/authorization URL, missing openid-configuration discovery, custom launch flows omitting claims Canvas requires, and environment/domain mismatches between the tool config and the Canvas host receiving the launch.
Understand the failure class
Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.
Related errors
- Invalid access token field/s: #
- Missing claims
- Access token expired
- Access token invalid - signature likely incorrect
- either the tool proxy or developer key were not found
AI-assisted analysis of instructure/canvas-lms@1c9f0bb801 (2026-09-15).
Data as JSON: /api/errors/7f5df5126e77b7c3.
Report an issue: GitHub.
Appendix: source
Thrown at lib/lti/oauth2/authorization_validator.rb:47
class InvalidAuthJwt < StandardError
end
class MissingAuthorizationCode < StandardError
end
def initialize(jwt:, authorization_url:, code: nil, context:)
@raw_jwt = jwt
@authorization_url = authorization_url
@code = code
@context = context
end
def jwt
@_jwt ||= begin
validated_jwt = JSON::JWT.decode @raw_jwt, jwt_secret
validator = Canvas::Security::JwtValidator.new jwt: validated_jwt, expected_aud: @authorization_url, override_sub: sub
unless validator.valid?
raise InvalidAuthJwt, validator.error_message
end
validated_jwt
end
end
alias_method :validate!, :jwt
def tool_proxy
@tool_proxy ||=
if (tp = ToolProxy.where(guid: unverified_jwt[:sub], workflow_state: "active").first)
developer_key = tp.product_family.developer_key
raise InvalidAuthJwt, "the Developer Key is not active or available in this environment" if developer_key.present? && !developer_key.usable?
ims_tool_proxy = ::IMS::LTI::Models::ToolProxy.from_json(tp.raw_data)
unless ims_tool_proxy.enabled_capabilities.intersect?(["Security.splitSecret", "OAuth.splitSecret"])
raise InvalidAuthJwt, "the Tool Proxy must be using a split secret"
endView on GitHub (pinned to 1c9f0bb801)