oauth-scope

Scopes are enforced and tokens are read from the header only

shipped

The sections below are generated from the CLI (methodology 0.3-draft, CLI 0.3.0); the same text is printed by sealmark explain oauth-scope.

pack
oauth opt-in pack
weight
scoredcandidate
scope
server as deployed (the bearer tokens the endpoint accepts, and what it tells a client that has none)
decided by
http
since
0.3-draft (methodology 0.3-draft)
config keys
checks.oauth-scope.enabledchecks.oauth-scope.severityoauth.audienceoauth.issueroauth.scopesserver.headersserver.url

Probes

  • control a well-formed RS256 token from the harness's issuer, unexpired, with the right iss, aud and scope: initialize and tools/list must succeed, or the check is inconclusive
  • no-scope no scope claim (only when oauth.scopes is declared): refused, 403 insufficient_scope preferred, 401 accepted
  • unrelated-scope scope "sm-unrelated" (only when oauth.scopes is declared): refused, 403 insufficient_scope preferred, 401 accepted
  • query-token the control token as ?access_token= in the URL and no Authorization header: never served (RFC 6750 2.3; the MCP specification requires the header)

Settings and thresholds

Every check also takes checks.oauth-scope.enabled and checks.oauth-scope.severity; they are described once in the configuration reference.

This check has no numeric threshold.

KeyDefaultStandardMeaning
oauth.audiencenonenot lockedThe audience (RFC 8707 resource) the stub's tokens carry and the protected-resource metadata must name; the value of {audience}. null uses server.url in canonical form (no fragment, no trailing slash).
oauth.issuernonenot lockedWhich OAuth issuer the server under test trusts. stub starts the harness's own authorization server on loopback (flag --oauth): {issuer} and {jwks} name it, and the caller identities ({bearer}, {bearer:a}, {bearer:b}) become tokens it signed for {audience}, sent as Authorization: Bearer. Point the server's issuer, JWKS URL and audience at those placeholders in server.env or server.args. external declares a server that trusts its own issuer: only the checks that need no harness-signed token run (oauth-discovery). null declares nothing.
oauth.scopes[]not lockedScopes the server requires: the control token carries them, and oauth-scope sends a token without them, which must be refused. Empty skips the scope probe.
server.headersnonenot lockedHTTP headers sent to the server (url transport). Values under secret-looking names are redacted.
server.urlnonenot lockedURL of a Streamable HTTP server. Alone it must already be running; with command, Sealmark starts it and waits for this URL.

How the verdict is established

  • Scoring. scored: pass = 1 point, part = 0.5, fail = 0. A fail lowers the pack's percentage but never caps the letter.

What to fix

Read the token from the Authorization header only, never from an access_token query parameter (URLs end up in logs). Refuse a token that lacks a scope the request needs, preferably with 403 and WWW-Authenticate: Bearer error="insufficient_scope", scope="<needed>". The scopes the check expects are oauth.scopes.

False positives

None known: the scope probes run only for scopes the configuration says the server requires.

False negatives

Scopes are checked on initialize and tools/list only; a server that enforces scopes per tool is not probed tool by tool. Without oauth.scopes only the query-token leg runs.