{"record":{"id":"66824df9b778746e","repo":"instructure/canvas-lms","slug":"invalid-grant-authorization-controller","errorCode":null,"errorMessage":"invalid_grant","messagePattern":"invalid_grant","errorType":"http","errorClass":null,"httpStatus":400,"severity":"error","filePath":"app/controllers/lti/ims/authorization_controller.rb","lineNumber":86,"sourceCode":"          format: [\"application/json\"].freeze,\n          action: [\"POST\"].freeze\n        }.freeze\n      ].freeze\n\n      class InvalidGrant < RuntimeError; end\n      JWT_GRANT_TYPE = \"urn:ietf:params:oauth:grant-type:jwt-bearer\"\n      AUTHORIZATION_CODE_GRANT_TYPE = \"authorization_code\"\n      GRANT_TYPES = [JWT_GRANT_TYPE, AUTHORIZATION_CODE_GRANT_TYPE].freeze\n\n      rescue_from JSON::JWS::VerificationFailed,\n                  JSON::JWT::InvalidFormat,\n                  JSON::JWS::UnexpectedAlgorithm,\n                  Lti::OAuth2::AuthorizationValidator::InvalidAuthJwt,\n                  Lti::OAuth2::AuthorizationValidator::SecretNotFound,\n                  Lti::OAuth2::AuthorizationValidator::MissingAuthorizationCode,\n                  InvalidGrant do |e|\n        Lti::Errors::ErrorLogger.log_error(e)\n        render json: { error: \"invalid_grant\" }, status: :bad_request\n      end\n      # @API authorize\n      #\n      # Returns an access token that can be used to access other LTI services\n      #\n      # @argument grant_type [Required, String]\n      #  When using registration provided credentials it should contain the exact value of:\n      #  \"urn:ietf:params:oauth:grant-type:jwt-bearer\" once a tool proxy is created\n      #  When using developer credentials it should have the value of: \"authorization_code\" and pass\n      #  the optional argument `code` defined below\n      #\n      # @argument code [optional, String]\n      #   Only used in conjunction with a grant type of \"authorization_code\".  Should contain the \"reg_key\" from the\n      #   registration message\n      #\n      # @argument assertion [Required, AuthorizationJWT]\n      #   The AuthorizationJWT here should be the JWT in a string format\n      #","sourceCodeStart":68,"sourceCodeEnd":104,"githubUrl":"https://github.com/instructure/canvas-lms/blob/1c9f0bb8013ed69c4f2efe11fd483025469b7e6c/app/controllers/lti/ims/authorization_controller.rb#L68-L104","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: tool signs client_assertion with HS256\nconst assertion = jwt.sign(payload, clientSecret, { algorithm: 'HS256' });\n// after: use the asymmetric algorithm registered on the developer key\nconst assertion = jwt.sign(payload, privateKey, {\n  algorithm: 'RS256',\n  keyid: kid,\n  expiresIn: '5m'\n});","handlingStrategy":"try-catch","validationCode":"// before calling /ims/authorize\nconst payload = { iss: clientId, sub: clientId, aud: authUrl, exp: Math.floor(Date.now()/1000) + 300, iat: Math.floor(Date.now()/1000) };\nif (!code || Date.now() - codeIssuedAt > 60_000) throw new Error('authorization code missing or possibly expired — restart OIDC flow');\nif (!devKeyActive) throw new Error('developer key missing or inactive');","typeGuard":"function isInvalidGrantResponse(res) {\n  return res.status === 400 && typeof res.body === 'object' && res.body !== null && res.body.error === 'invalid_grant';\n}","tryCatchPattern":"try {\n  const res = await fetch(authorizeUrl, { method: 'POST', body: assertionBody });\n  if (!res.ok) throw Object.assign(new Error('token request failed'), { status: res.status, body: await res.json() });\n} catch (e) {\n  if (isInvalidGrantResponse(e)) {\n    logServerSideHint(e.body); // body is opaque; check Canvas ErrorReports\n    restartOidcLoginFlow(); // fresh authorization code\n  } else {\n    throw e;\n  }\n}","preventionTips":["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."],"tags":["lti","oauth2","oidc","http-400","authentication"],"backgroundTag":"oauth-token-exchange-failed","analyzedSha":"1c9f0bb8013ed69c4f2efe11fd483025469b7e6c","analyzedAt":"2026-09-15T20:33:18.891Z","contentChangedAt":"2026-09-15T20:33:18.891Z","schemaVersion":2},"datasetVersion":"2026-09-23T02:17:17.105Z"}