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

  1. 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.
  2. 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.
  3. Re-run the OIDC launch/login flow from the start to obtain a fresh authorization code; never replay or cache codes.
  4. 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

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


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)