Request path normalization and validation
Ory Oathkeeper decides whether to allow a request by matching its URL path against your access rules. The service that finally handles the request parses the same path to route it and to enforce its own checks. When Oathkeeper and the upstream service interpret the same raw path differently, an attacker can craft a path that Oathkeeper matches to a permissive rule (or to no rule) while the upstream routes it to a protected resource. This is a path confusion or parser differential attack, and it can lead to authorization bypass and path traversal.
The security configuration section closes this gap. Oathkeeper normalizes and validates the request path so that the path it
matches rules against is the same canonical path the upstream receives.
The default settings are secure. Only relax them if you fully understand how your upstream parses paths and you have confirmed it resolves the affected sequences the same way Oathkeeper does. Every relaxed setting re-opens a path-confusion class that the default closes.
Why these settings are necessary
A URL path can be written in many forms that are technically different byte sequences but that some parsers treat as equivalent.
For example, all of the following can resolve to /admin in one system or another:
/public/../admin— a..traversal segment/public%2f..%2fadmin— a percent-encoded slash (%2f=/)/public/%2e%2e/admin— percent-encoded dots (%2e=.)/public/%252e%252e/admin— double-encoded dots (%25=%, so%252edecodes to%2e, then to.)/public\..\admin— a backslash that some backends treat as a path separator/admin%00.png— a null byte that truncates the path in some languages
If Oathkeeper matches a rule against the literal string but the upstream first decodes or resolves these sequences, the two
systems disagree about which resource is being accessed. An access rule that protects /admin is then useless, because the
attacker never sends the literal string /admin to Oathkeeper.
Oathkeeper removes the ambiguity in two ways:
- Normalization rewrites the path into a single canonical form (RFC 3986 §6), so equivalent inputs collapse to one string before rule matching.
- Validation rejects encodings that have no legitimate purpose in normal traffic and are characteristic of attacks, such as null bytes and multi-encoded traversal sequences.
Proxy mode and Decision mode differ
Oathkeeper applies path security differently depending on how you run it, because the two modes have different control over what the upstream ultimately receives. Configure each mode under its own key:
security.proxyfor the reverse proxy.security.decisionfor the Access Control Decision API.
Proxy mode normalizes the path
As a reverse proxy, Oathkeeper controls exactly what the upstream receives. It normalizes the path itself, matches rules against the normalized path, and forwards the normalized path to the upstream. Because both Oathkeeper and the upstream now see the same canonical path, there is no ambiguity to exploit.
Oathkeeper rejects a proxied request with 400 Bad Request only when the path is malformed (for example a truncated
percent-encoding like /%x) or when it still contains a rejected sequence after normalization (for example a double-encoded
traversal or a null byte).
For example, with the default settings:
| Incoming request | Forwarded to upstream |
|---|---|
/foo/../bar | /bar |
/foo//bar | /foo/bar |
/foo\bar | /foo/bar |
/foo%2fbar | /foo/bar |
/foo/%252e%252e/bar | rejected with 400 |
Decision mode requires a sanitized path
As a Decision API, Oathkeeper only returns an allow or deny verdict. Your API gateway (Ambassador, Envoy, Kong, NGINX, Traefik, and similar) forwards the original request to the upstream, not a path rewritten by Oathkeeper. Oathkeeper therefore cannot safely normalize on the caller's behalf: if it matched rules against a normalized path but the gateway forwarded a different raw path, it would reintroduce the exact mismatch it is trying to prevent.
Instead, Oathkeeper requires the caller to send an already-normalized path and rejects anything that is not canonical with
403 Forbidden. The security.decision.normalize_path settings define what Oathkeeper considers canonical; the
unnormalized_path setting controls whether a non-canonical path is rejected. The validation settings reject the same dangerous
sequences as in proxy mode.
For example, with the default settings each of these is rejected with 403:
/decisions/public/%2e%2e/admin— encoded traversal/decisions/public%2ffile— encoded slash/decisions/public/%252e%252e/admin— double-encoded traversal/decisions/pub%6cic— unnecessary encoding (%6c=l), so the path is not canonical
Independently of these settings, Oathkeeper always rejects a request whose /decisions prefix itself is percent-encoded, because
there is no legitimate reason to escape it.
Configuration
The defaults are secure; the block below is only shown to make every key explicit. You do not need to set any of these to get the recommended behavior.
security:
proxy:
# Ignore or trust incoming X-Forwarded-* headers.
x_forwarded_headers: ignore # ignore (default) | trust
normalize_path:
backslashes: force_forward # force_forward (default) | preserve
percent_encoded_slashes: force_decode # force_decode (default) | preserve
validate_path:
double_encoded_traversal: reject # reject (default) | allow
multi_encoded_null_byte: reject # reject (default) | allow
literal_null_byte: reject # reject (default) | allow
decision:
x_forwarded_headers: ignore # ignore (default) | trust
normalize_path:
backslashes: force_forward # force_forward (default) | preserve
percent_encoded_slashes: force_decode # force_decode (default) | preserve
validate_path:
double_encoded_traversal: reject # reject (default) | allow
multi_encoded_null_byte: reject # reject (default) | allow
literal_null_byte: reject # reject (default) | allow
# Only available in decision mode.
unnormalized_path: reject # reject (default) | allow
Normalization settings
These settings live under normalize_path and apply to both modes. In proxy mode they control how Oathkeeper rewrites the path;
in decision mode they define the canonical form the caller must already send.
backslashes
force_forward(default, recommended): replace\with/. A backslash is not a valid path separator per RFC 3986, but Windows-based and some other backends treat it as one, so\and/create ambiguity.preserve: keep backslashes. Only safe if your upstream never treats\as a path separator. If it does, an attacker can use\to reach a segment your rules do not cover.
percent_encoded_slashes
force_decode(default, recommended): decode%2fto/. Because Oathkeeper cannot know whether the upstream treats an encoded slash as a path separator, decoding is the only choice that removes the ambiguity.preserve: keep%2fencoded. Only safe if your upstream treats an encoded slash as a literal character that is distinct from a real path separator and never as a delimiter. Otherwise,%2flets an attacker smuggle extra path segments past rule matching.
Validation settings
These settings live under validate_path and apply to both modes. Each rejects a class of encoding that has no legitimate purpose
in normal traffic and is characteristic of attacks. The default settings are deliberately conservative, so they can also reject
unusual but harmless inputs — for example a filename that literally contains %2e, which a client must send as %252e. This is
intentional: for a path, ambiguity is itself a security risk, so Oathkeeper rejects rather than guesses. If a rejected encoding is
genuinely meaningful to your upstream, set the corresponding option to allow, but ensure that all services in the request
handling chain correctly handle the input.
double_encoded_traversal
reject(default, recommended): reject multi-encoded traversal sequences such as%252e%252e(which decodes to%2e%2eand then to..), as well as encoded slashes and backslashes hidden by extra layers of encoding.allow: permit them. Only safe if your upstream does not decode more than once. A backend that decodes twice would resolve%252e%252eto..and traverse directories, while Oathkeeper matched a harmless-looking string.
multi_encoded_null_byte
reject(default, recommended): reject multi-encoded null bytes such as%2500.allow: permit them. Only safe if your upstream never decodes a null byte into an actualNUL. A null byte can truncate a path in C-based string handling, so the upstream might act on a shorter path than the one Oathkeeper matched.
literal_null_byte
reject(default, recommended): reject a literal encoded null byte (%00) in the path.allow: permit it, with the same caveats and risks asmulti_encoded_null_byte.
unnormalized_path (decision mode only)
reject(default, recommended): reject any request whose path is not already in canonical form. This is what forces the caller to send a sanitized path in decision mode.allow: skip the canonical-form check. Only safe if you are certain the component that forwards the request to the upstream uses the exact same raw path Oathkeeper matched against, with no decoding or resolution in between. If it does not, your rules and the upstream can disagree about the resource being accessed.
This setting does not exist for the proxy, because the proxy normalizes the path itself and forwards the canonical form.
X-Forwarded headers
x_forwarded_headers controls whether Oathkeeper reads the request method, scheme, host, and URI from incoming
X-Forwarded-Method, X-Forwarded-Proto, X-Forwarded-Host, and X-Forwarded-Uri headers.
ignore(default, recommended): ignore these headers and derive the values from the request itself. Use this unless Oathkeeper runs behind a trusted reverse proxy or load balancer that sets these headers and strips any that the client sent.trust: read the values from the headers. A client that can setX-Forwarded-*headers directly can otherwise spoof the method, host, or path Oathkeeper matches rules against, bypassing authorization.
Decision API and ForwardAuth integrations. API gateways that call the Decision API through a ForwardAuth-style middleware (for
example Traefik, NGINX auth_request, Ambassador, or Envoy ext_authz) send the original request
details in X-Forwarded-* headers. For these setups you must set:
security:
decision:
x_forwarded_headers: trust
Only do this when the gateway is trusted and it overwrites or removes any X-Forwarded-* headers a client might have sent.
Deprecated serve.proxy.trust_forwarded_headers
The serve.proxy.trust_forwarded_headers boolean is deprecated and will be removed in a future release. It still works and is
equivalent to security.proxy.x_forwarded_headers: trust, but you should migrate to the new key:
# Before
serve:
proxy:
trust_forwarded_headers: true
# After
security:
proxy:
x_forwarded_headers: trust
Error responses
| Situation | Proxy mode | Decision mode |
|---|---|---|
Path rejected by a validate_path rule | 400 Bad Request | 403 Forbidden |
Path not in canonical form (unnormalized_path) | not applicable | 403 Forbidden |
Malformed percent-encoding (for example /%x) | 400 Bad Request | 403 Forbidden |
Escaped /decisions prefix | not applicable | 403 Forbidden |
The proxy returns 400 Bad Request because a rejected path is a malformed request to the proxy. The Decision API returns
403 Forbidden because it expresses an authorization verdict: the request, as sent, is not allowed.
