Ory Oathkeeper Rule Ordering: Why Your JWT Rules Silently Fail
Ory Oathkeeper stops at the first matching access rule and never hot-reloads file-based config. Learn how rule ordering, case-sensitive JWT claims, and restarts cause silent routing failures — and how to verify which rule actually fired.
02 Mar 2026, 06:17 UTC

The short answer
Ory Oathkeeper evaluates access rules sequentially and stops at the first match. If a broad rule sits above a specific one, the specific rule never runs — and because Oathkeeper does not hot-reload rules, a misordered config stays live until you restart the process. Most "Oathkeeper is ignoring my JWT rule" reports come down to one of three things: rule order, case-sensitive claim matching, or a stale process holding old rules.
This guide walks through a working rule configuration, explains the matching mechanism, and shows how to verify which rule actually fired.
How rule evaluation works
Oathkeeper is a reverse proxy that sits in front of your upstream services. Each incoming request is tested against your rule list from top to bottom. A rule matches only when all of its conditions are satisfied:
- URL/path pattern — matched against the request path (exact or glob, depending on your matcher config).
- HTTP methods — the request method must be in the rule's list.
- Authenticators, authorizer, mutators — the pipeline that runs when the rule matches. An authenticator like
oauth2_introspectionorjwtvalidates the token; a failed authentication means the request is rejected, not passed to the next rule.
The critical behavior: evaluation stops at the first rule whose URL and method match. Oathkeeper does not keep looking for a "better" rule further down. If the matched rule's authenticator rejects the token, the request fails — even if a later rule would have accepted it.
A worked configuration
Assume an upstream service at http://billing:8080 that should accept requests only when the caller's JWT carries the scope billing:read. Two endpoints exist: a public health check and the protected API. A correct rule file (Oathkeeper 0.40-style schema) looks like this:
# access-rules.yml
- id: "billing:health"
upstream:
url: "http://billing:8080"
match:
url: "http://localhost:4455/billing/health"
methods: ["GET"]
authenticators:
- handler: noop
authorizer:
handler: allow
mutators:
- handler: noop
- id: "billing:read"
upstream:
url: "http://billing:8080"
match:
url: "http://localhost:4455/billing/<.*>"
methods: ["GET"]
authenticators:
- handler: jwt
config:
jwks_urls:
- "https://auth.example.com/.well-known/jwks.json"
required_scope: ["billing:read"]
authorizer:
handler: allow
mutators:
- handler: id_tokenOrder matters here. The health rule is first because its path is more specific. If you reversed them, the glob /billing/<.*> would also match /billing/health, and unauthenticated health probes would start failing JWT validation.
Point Oathkeeper at the file in oathkeeper.yml:
access_rules:
repositories:
- file:///etc/oathkeeper/access-rules.ymlVerifying which rule fired
Run Oathkeeper locally (the ory CLI or the oryd/oathkeeper container both work; commands assume a local checkout or container shell with the config mounted):
ory serve --config oathkeeper.ymlThen send a request with a valid token:
curl -i -H "Authorization: Bearer $TOKEN" http://localhost:4455/billing/invoicesA 200 from the upstream confirms the billing:read rule matched and the scope check passed. A 401 or 403 means the rule matched but authentication or authorization failed; a 404 from Oathkeeper itself means no rule matched.
To see the evaluation directly, enable debug logging before starting the process (requires permission to set environment variables for the Oathkeeper process):
export ORY_LOGIT_DEBUG=*
ory serve --config oathkeeper.ymlThe logs show which rule ID handled each request. If the ID is not the one you expected, ordering is your problem. Do not leave debug logging on in production — it can log sensitive request data.
Common mistakes
1. Catch-all rules at the top
A rule matching /<.*> placed first shadows everything below it. Put catch-alls (including deny-by-default rules) last, and review the file top-down whenever you add a rule.
2. Case-sensitive claim and scope values
Matching is exact and case-sensitive. A token carrying Billing:Read will not satisfy required_scope: ["billing:read"]. If a rule "should" match but doesn't, decode the JWT payload (for example with jwt.io or jq -R 'split(".")[1] | @base64d | fromjson' on the token) and compare the claim strings character by character.
3. Editing rules without restarting
Oathkeeper does not hot-reload file-based rules. Changes take effect only after a process restart. In production, use rolling restarts (or run multiple replicas behind a load balancer) so at least one instance keeps serving while others reload. If you need dynamic rule updates, use the HTTP repository option and Oathkeeper's rule API instead of files — but verify the reload semantics for your version, as this behavior has changed across releases.
4. Assuming a failed authenticator falls through
It does not. Once a rule matches on URL and method, its authenticator's verdict is final. You cannot express "try JWT auth, else try API key" as two stacked rules on the same path — instead, configure multiple authenticators within a single rule, or split the paths.
Limits to keep in mind
- Rule schemas and config keys differ between Oathkeeper versions; the examples above follow the 0.4x layout. Check
oathkeeper versionand your version's docs before copying. - File-based rules require restart; plan deployments accordingly.
- Debug logging is the reliable way to diagnose matching, but treat its output as sensitive.
The practical checklist when a rule misbehaves: confirm the process restarted since your last edit, decode the token and check claim casing, then read debug logs to see which rule ID actually matched. Those three steps resolve the large majority of Oathkeeper routing surprises.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.