# OAuth & scopes

> Authorization problems — scopes that do not appear in the Canvas UI, repeated authorization prompts, and students who cannot connect Canvas.

[Canonical HTML page](https://safeonlineexam.com/docs/troubleshooting/oauth-scopes)

These appear during the Canvas OAuth "Connect Canvas" flow, or when the tool tries
to use the Canvas API on someone's behalf.

## A scope does not appear in the Developer Keys UI [#a-scope-does-not-appear-in-the-developer-keys-ui]

Some Canvas environments do not surface every endpoint scope in the UI. This most
often affects `url:GET|/api/v1/login/session_token`, which the SEB session handoff
requires.

<Callout type="warn" title="Use the API OAuth key, not the LTI key">
  Update the **API OAuth Developer Key** configured as `CANVAS_API_CLIENT_ID` — not
  the separate LTI 1.3 Developer Key. The exact scope is
  `url:GET|/api/v1/login/session_token`. Do not substitute
  `url:GET|/login/session_token`, a `logins` scope, or a generic login permission.
</Callout>

Canvas's normal editor can omit this scope. Use the root-account Developer Keys
REST API to read the existing key, merge the required scope into its current list,
and write that list back. Canvas documents
[`PUT /api/v1/developer_keys/:id`](https://canvas.instructure.com/doc/api/developer_keys.html)
and `developer_key[scopes]` for this purpose.

<Steps>
  <Step>
    ### Create a temporary administrator token [#create-a-temporary-administrator-token]

    Have a **root-account Canvas administrator** create a short-lived personal access
    token with permission to manage Developer Keys. In Terminal, start `bash`, then
    run the following preflight. Replace the Canvas origin and API OAuth Developer Key
    ID; `1` is the usual root account ID but must match your Canvas root account.

    ```bash
    set -euo pipefail

    export CANVAS_ORIGIN='https://your-school.instructure.com'
    export ROOT_ACCOUNT_ID='1'
    export DEVELOPER_KEY_ID='REPLACE_WITH_THE_API_OAUTH_KEY_ID'
    export SESSION_SCOPE='url:GET|/api/v1/login/session_token'

    read -r -s -p 'Temporary root-admin Canvas token: ' CANVAS_ADMIN_TOKEN
    printf '\n'

    KEY_JSON="$(
      curl --fail-with-body --silent --show-error \
        -H "Authorization: Bearer ${CANVAS_ADMIN_TOKEN}" \
        "${CANVAS_ORIGIN}/api/v1/accounts/${ROOT_ACCOUNT_ID}/developer_keys?per_page=100" |
      jq -ce --arg id "${DEVELOPER_KEY_ID}" '
        map(select((.id | tostring) == $id)) |
        if length == 1 then .[0] else error("Developer Key not found") end
      '
    )"

    printf '%s\n' "${KEY_JSON}" | jq '{id, name, is_lti_key, require_scopes, scopes}'
    ```
  </Step>

  <Step>
    ### Confirm the key before changing it [#confirm-the-key-before-changing-it]

    Confirm that the displayed key is the intended API OAuth key and that
    `"is_lti_key": false`. If it is an LTI key, stop and locate the API OAuth key
    instead.
  </Step>

  <Step>
    ### Merge the scope, update the key, and verify [#merge-the-scope-update-the-key-and-verify]

    Run this in the same `bash` session. It changes only the key's `scopes` property
    and preserves every scope already present.

    ```bash
    EXISTING_SCOPES="$(printf '%s' "${KEY_JSON}" | jq -c '.scopes // []')"

    UPDATED_SCOPES="$(
      printf '%s' "${EXISTING_SCOPES}" |
      jq -c --arg scope "${SESSION_SCOPE}" '
        if index($scope) then . else . + [$scope] end
      '
    )"

    PAYLOAD="$(jq -cn --argjson scopes "${UPDATED_SCOPES}" '{developer_key: {scopes: $scopes}}')"

    UPDATED_KEY="$(
      curl --fail-with-body --silent --show-error \
        -X PUT \
        -H "Authorization: Bearer ${CANVAS_ADMIN_TOKEN}" \
        -H 'Content-Type: application/json' \
        --data "${PAYLOAD}" \
        "${CANVAS_ORIGIN}/api/v1/developer_keys/${DEVELOPER_KEY_ID}"
    )"

    printf '%s\n' "${UPDATED_KEY}" |
      jq '{id, name, require_scopes, scopes}'

    printf '%s\n' "${UPDATED_KEY}" |
      jq -e --arg scope "${SESSION_SCOPE}" '.scopes | index($scope) != null' >/dev/null

    echo 'Verified: session-token scope is present.'
    unset CANVAS_ADMIN_TOKEN KEY_JSON EXISTING_SCOPES UPDATED_SCOPES PAYLOAD UPDATED_KEY
    ```
  </Step>

  <Step>
    ### Revoke the temporary token and reconnect users [#revoke-the-temporary-token-and-reconnect-users]

    Immediately revoke the temporary administrator token in Canvas. Do not reopen and
    save this key through the normal scope UI afterward; that UI may omit the hidden
    scope again.

    Every user who connected before this change — administrators, instructors, and
    students — must select **Reconnect Canvas** and authorize once. Existing OAuth
    tokens do not gain new Developer Key scopes automatically. See
    [Canvas Developer Key scopes](https://canvas.instructure.com/doc/api/file.developer_keys.html).
  </Step>
</Steps>

The application already requests this exact scope in its API OAuth configuration.
Canvas uses it for the official session-start endpoint, which returns a short-lived
browser session URL for quiz taking. See
[Canvas OAuth session-token documentation](https://canvas.instructure.com/doc/api/file.oauth_endpoints.html)
and the full [API OAuth key](/docs/canvas/api-oauth-key) scope list.

## Course reset repeatedly asks to reconnect [#course-reset-repeatedly-asks-to-reconnect]

Confirm that the same API OAuth Developer Key also permits the administrator-only
scope `url:GET|/api/v1/courses/:course_id/quizzes/:id`. A root administrator must
select **Reconnect Canvas** once after that scope is added; instructor and student
connections do not need to change for this administrator capability. The reset
uses this exact read scope to record each Classic Quiz access code before it makes
any change.

## The authorization window does not return to the tool [#the-authorization-window-does-not-return-to-the-tool]

Authorization must start from the Safe Online Exam page opened by a signed LTI
launch. The service opens Canvas authorization in a popup and, after the callback,
resumes the original role-appropriate page through an exact-origin popup
handshake; the callback itself does not render a privileged workspace.

**Fix:** allow the popup for the tool and Canvas origins, begin again from the
course or account-navigation tool entry, and do not bookmark or open the callback
URL directly. If the browser cannot complete the popup exchange, reopen the tool
from Canvas and retry the connection.

## Instructor is asked to authorize repeatedly [#instructor-is-asked-to-authorize-repeatedly]

The OAuth callback or key configuration does not match.

**Fix — confirm all three:**

* The API OAuth **redirect URI** exactly matches `${TOOL_URL}/api/oauth2callback`.
* The OAuth key is **enabled**.
* The configured `CANVAS_API_CLIENT_ID` / `CANVAS_API_CLIENT_SECRET` are the
  **API-key** values — not the LTI key's.

If you changed the redirect URI or scope set, affected users must reauthorize once.

## Student cannot connect Canvas or the configuration download fails [#student-cannot-connect-canvas-or-the-configuration-download-fails]

Usually a missing scope or a Canvas-environment mismatch.

**Fix:**

* Confirm the exact `session_token` scope is allowed on the API key.
* Confirm the student is authorizing the **same Canvas environment** as the LTI
  launch — connecting a different Canvas will not satisfy the launch.
* Have the student run the [setup check](/docs/operate/student#run-the-setup-check-recommended)
  to test the missing/revoked-scope recovery path, which requests a fresh Canvas
  connection.

## Why every connection requests the same scopes [#why-every-connection-requests-the-same-scopes]

By design, every Canvas connection requests the same complete application scope set
regardless of the user's role in the initiating course. This keeps one durable
grant valid for someone who is an instructor in one course and a student in
another. Canvas still enforces their actual permissions, so a broad scope request
does not grant broad access — it only avoids incompatible role-specific grants. An
administrator's grant is the same record, upgraded in place with the account scopes.

## Where tokens live [#where-tokens-live]

One OAuth grant is stored per Canvas user in `canvas_oauth_tokens`; the service
refreshes it as needed, and a `401` from Canvas triggers at most one safe
refresh/retry. If authorization is badly wedged for one user, having them
**Reconnect Canvas** issues a fresh grant. Rotating `STATE_ENCRYPTION_KEY`
invalidates in-flight OAuth state (not stored grants) — see
[Configuration → Secret rotation](/docs/deploy/configuration#secret-rotation).