Summary
The introspection-skip secret is validated by comparing the entire raw Authorization header value against the configured secret. This requires the header to be sent without an auth-scheme (Authorization: <secret>), which:
- is non-conformant with RFC 7235 §2.1 (the
auth-scheme is mandatory),
- cannot traverse any standards-compliant
Authorization-parsing intermediary (ext_authz / API gateway / proxy), and
- collides with the router's own JWT
Bearer authentication, so there is no spec-conformant way to present the secret.
Request: make the introspection credential's scheme (and ideally header) configurable, and dispatch on the scheme so the secret never enters JWT validation.
Current behavior
AccessController.IntrospectionAccess compares the whole header value to the secret:
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return false
}
authHeader = strings.TrimSpace(authHeader)
return subtle.ConstantTimeCompare([]byte(authHeader), []byte(a.introspectionSkipSecret)) == 1
Because the full header is compared, the value must be sent scheme-less: Authorization: <secret>.
Why this is a problem
1. RFC 7235 non-conformance. The Authorization header is Authorization = credentials, and credentials require a scheme:
RFC 7235 §2.1 — credentials = auth-scheme [ 1*SP ( token68 / #auth-param ) ]
The auth-scheme sits outside the optional brackets — it is mandatory. A scheme-less Authorization value is malformed per the grammar. (https://datatracker.ietf.org/doc/html/rfc7235#section-2.1)
2. Breaks behind standards-compliant intermediaries. Any Envoy ext_authz service, API gateway, or proxy that parses the Authorization scheme (as the RFC intends) rejects a scheme-less value before it reaches the router. In our deployment an Envoy ext_authz authorizer returns 401 on the scheme-less secret, so introspection can never reach the router where the secret would be checked.
3. Collides with the router's own JWT auth. With authentication.jwt configured (header_name: Authorization, header_value_prefix: Bearer), the secret cannot be presented as Authorization: Bearer <secret> — the JWT authenticator would pick it up and attempt to validate it as a JWT. That leaves only the scheme-less form, which is exactly the form that fails (1) and (2).
Net: there is no way to present the introspection secret in a spec-conformant Authorization header when JWT auth is also enabled.
Note: Bearer is not the right scheme to mandate here anyway — the JWT authenticator owns Bearer, and a distinct scheme cleanly separates the two credential types.
Proposed fix
Primary — configurable scheme + dispatch by scheme.
Add an introspection.secret_scheme option. When set, the router expects Authorization: <scheme> <secret>, strips the (case-insensitive) scheme, and constant-time compares the remainder. When unset, current scheme-less behavior is preserved. The prehandler dispatches on scheme, so <scheme> <secret> is never fed to JWT validation and Bearer <jwt> is never fed to the introspection check.
introspection:
secret: '…'
secret_scheme: 'Token' # optional auth-scheme expected on the Authorization header
Sketch:
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if authHeader == "" {
return false
}
presented := authHeader
if a.introspectionSecretScheme != "" {
scheme, token, found := strings.Cut(authHeader, " ")
if !found || !strings.EqualFold(scheme, a.introspectionSecretScheme) {
return false // wrong/absent scheme → not an introspection credential
}
presented = strings.TrimSpace(token)
}
return subtle.ConstantTimeCompare([]byte(presented), []byte(a.introspectionSkipSecret)) == 1
Token is a good default choice (real precedent: GitHub used Authorization: token <PAT>); any value matching the RFC 7235 token ABNF works.
Alternative — dedicated configurable header.
Let the secret ride on its own header instead of Authorization, sidestepping the Authorization/JWT overlap entirely:
introspection:
secret: '…'
secret_header: 'X-Introspection-Token'
Backward compatibility
Both proposals are non-breaking: with secret_scheme / secret_header unset, behavior is identical to today (scheme-less Authorization compare). Existing deployments are unaffected.
Environment
- Reproduces on
main and on router commit b9dd6b20d1d3.
- Config:
authorization.require_authentication: true, authentication.ignore_introspection: true, introspection.secret set, authentication.jwt enabled with the default Bearer prefix.
Summary
The introspection-skip secret is validated by comparing the entire raw
Authorizationheader value against the configured secret. This requires the header to be sent without anauth-scheme(Authorization: <secret>), which:auth-schemeis mandatory),Authorization-parsing intermediary (ext_authz / API gateway / proxy), andBearerauthentication, so there is no spec-conformant way to present the secret.Request: make the introspection credential's scheme (and ideally header) configurable, and dispatch on the scheme so the secret never enters JWT validation.
Current behavior
AccessController.IntrospectionAccesscompares the whole header value to the secret:main: https://github.com/wundergraph/cosmo/blob/main/router/core/access_controller.go#L65-L80Because the full header is compared, the value must be sent scheme-less:
Authorization: <secret>.Why this is a problem
1. RFC 7235 non-conformance. The
Authorizationheader isAuthorization = credentials, and credentials require a scheme:The
auth-schemesits outside the optional brackets — it is mandatory. A scheme-lessAuthorizationvalue is malformed per the grammar. (https://datatracker.ietf.org/doc/html/rfc7235#section-2.1)2. Breaks behind standards-compliant intermediaries. Any Envoy
ext_authzservice, API gateway, or proxy that parses theAuthorizationscheme (as the RFC intends) rejects a scheme-less value before it reaches the router. In our deployment an Envoyext_authzauthorizer returns401on the scheme-less secret, so introspection can never reach the router where the secret would be checked.3. Collides with the router's own JWT auth. With
authentication.jwtconfigured (header_name: Authorization,header_value_prefix: Bearer), the secret cannot be presented asAuthorization: Bearer <secret>— the JWT authenticator would pick it up and attempt to validate it as a JWT. That leaves only the scheme-less form, which is exactly the form that fails (1) and (2).Net: there is no way to present the introspection secret in a spec-conformant
Authorizationheader when JWT auth is also enabled.Note:
Beareris not the right scheme to mandate here anyway — the JWT authenticator ownsBearer, and a distinct scheme cleanly separates the two credential types.Proposed fix
Primary — configurable scheme + dispatch by scheme.
Add an
introspection.secret_schemeoption. When set, the router expectsAuthorization: <scheme> <secret>, strips the (case-insensitive) scheme, and constant-time compares the remainder. When unset, current scheme-less behavior is preserved. The prehandler dispatches on scheme, so<scheme> <secret>is never fed to JWT validation andBearer <jwt>is never fed to the introspection check.Sketch:
Tokenis a good default choice (real precedent: GitHub usedAuthorization: token <PAT>); any value matching the RFC 7235tokenABNF works.Alternative — dedicated configurable header.
Let the secret ride on its own header instead of
Authorization, sidestepping theAuthorization/JWT overlap entirely:Backward compatibility
Both proposals are non-breaking: with
secret_scheme/secret_headerunset, behavior is identical to today (scheme-lessAuthorizationcompare). Existing deployments are unaffected.Environment
mainand on router commitb9dd6b20d1d3.authorization.require_authentication: true,authentication.ignore_introspection: true,introspection.secretset,authentication.jwtenabled with the defaultBearerprefix.