log: (1)
level: debug
tracing:
enabled: false
metrics:
enabled: false
serve: (2)
decision:
trusted_proxies:
- 0.0.0.0/0
mechanisms: (3)
authenticators:
- id: deny_all (4)
type: unauthorized
- id: anon (5)
type: anonymous
- id: jwt_auth (6)
type: jwt
config:
jwks_endpoint: http://idp:8080/.well-known/jwks
assertions:
issuers:
- demo_issuer
authorizers:
- id: opa (7)
type: remote
config:
endpoint: http://opa:8181/v1/data/{{ .Values.policy }}
payload: "{}"
expressions:
- expression: |
Payload.result == true
finalizers:
- id: create_jwt (8)
type: jwt
config:
signer:
key_store:
path: /etc/heimdall/signer.pem
- id: noop (9)
type: noop
default_rule: (10)
execute:
- authenticator: deny_all
- finalizer: create_jwt
providers:
file_system: (11)
src: /etc/heimdall/rules.yaml
watch: true
Protect an Application
This simple quickstart guide will walk you through the steps to protect an application using heimdall. You'll learn how to configure heimdall as an authentication and authorization proxy in front of your application, as well as how to implement an Edge-level Authorization Architecture (EAA) with heimdall's help.
Overview
In this guide, we’ll configure two setups to protect a service that exposes a few endpoints:
The
/public
endpoint is, as the name implies, public. Every request to it should be forwarded as is.The
/user
endpoint should only be accessible to users with theuser
role.The
/admin
endpoint should only be accessible to users with theadmin
role.The
/private
endpoint, along with any other potentially exposed endpoints, should not be accessible at all. All requests to it should be rejected.
For authentication, we’ll use JWTs containing the respective roles. Authorization will be handled with the help of Open Policy Agent (OPA).
In both setups, we’ll create minimal but complete environments using Docker Compose with:
Traefik as the edge proxy,
traefik/whoami (a service that echoes back everything it receives), mimicking our service exposing the endpoints described above,
an NGINX server serving the public key for verifying the JWTs (mimicking the JWKS endpoint typically exposed by an OIDC provider),
OPA, which evaluates the authorization policy,
heimdall, orchestrating everything to enforce the above requirements.
This quickstart and others demonstrating different integration options are also available on GitHub. |
Prerequisites
To follow this guide, you’ll need the following tools installed locally:
docker-compose, and
a text editor of your choice.
Configure
Heimdall can be configured via environment variables, as well as using a configuration file. For simplicity, we’ll use a configuration file in this guide. Create a file named
heimdall-config.yaml
with the following contents:1 Since heimdall emits logs at the error
level by default, and we want to monitor what’s happening, we’ll set the log level todebug
. This way, we’ll not only see the results of a particular rule execution (which you would see with theinfo
log level), but also detailed logs of what’s going on inside each rule. Additionally, we disable tracing and metrics collection, which are pulled by default to an OTEL agent, to avoid error messages related to the unavailability of the agent. For more information on available observability options, see the Observability chapter.2 This configuration instructs heimdall to trust X-Forwarded-*
headers from any source. We need this for integration with Traefik, which uses these headers while forwarding requests to heimdall. The IP address used depends on your local Docker configuration.Never use this in production - always restrict trusted IPs instead! Refer to the documentation on the trusted_proxies property and Security Considerations for more details. 3 Here, we define our catalogue of mechanisms to be used in upstream service-specific rules. In this case, we define authenticators, an authorizer, and finalizers. 4 These two lines define the unauthorized
authenticator nameddeny_all
, which rejects all requests.5 These two lines define the anonymous
authenticator namedanon
, which allows any request and creates a subject with the ID set toanonymous
. You can find more information about the subject and other objects here.6 This and the following lines define and configure the jwt
authenticator namedjwt_auth
. With this configuration, it will check if a request contains anAuthorization
header with a bearer token in JWT format and validate it using key material fetched from the JWKS endpoint. It will reject requests without a valid JWT or create a subject with thesub
claim set to the token’ssub
value. All other claims will also be added to the subject’s attributes.7 Here, we define and configure a remote
authorizer namedopa
. Note how we allow for the overriding of particular settings, which will be specified below when we define the rules.8 The following lines define the jwt
finalizer. This configuration will generate a JWT from the subject object with standard claims, setting thesub
claim to the subject’s ID. The key material used for signing is pulled from the referenced key store.9 These two lines conclude the definition of our mechanisms catalogue and define the noop
finalizer, which, as the name implies, does nothing.10 With the mechanisms catalogue in place, we can now define a default rule. This rule will be triggered if no other rule matches the request. It also acts as a base for defining regular (upstream service-specific) rules. This rule defines a secure default authentication & authorization pipeline, which denies any request using the deny_all
authenticator. If overridden by a regular rule, it will create a JWT using thejwt
finalizer.11 The last few lines configure the file_system
provider, which allows loading regular rules from the file system. The provider is also configured to watch for changes, so you can modify the rules in real time.Create a file named
signer.pem
with the following content. This file is our key store with a private key, which you’ll see referenced in the configuration above.-----BEGIN EC PRIVATE KEY----- MIGkAgEBBDALv/dRp6zvm6nmozmB/21viwFCUGBoisHz0v8LSRXGiM5aDywLFmMy 1jPnw29tz36gBwYFK4EEACKhZANiAAQgZkUS7PCh5tEXXvZk0LDQ4Xn4LSK+vKkI zlCZl+oMgud8gacf4uG5ERgju1xdUyfewsXlwepTnWuwhXM7GdnwY5GOxZTwGn3X XVwR/5tokqFVrFxt/5c1x7VdccF4nNM= -----END EC PRIVATE KEY-----
Do not use this for purposes beyond this tutorial! Now, create a rule file named
upstream-rules.yaml
to implement the authentication and authorization requirements for your service. Copy the following contents into it:version: "1alpha4" rules: - id: demo:public (1) match: routes: - path: /public forward_to: host: upstream:8081 execute: - authenticator: anon - finalizer: noop - id: demo:protected (2) match: routes: - path: /:user path_params: - name: user type: glob value: "{user,admin}" forward_to: host: upstream:8081 execute: - authenticator: jwt_auth - authorizer: opa config: values: policy: demo/can_access payload: | { "input": { "role": {{ quote .Subject.Attributes.role }}, "path": {{ quote .Request.URL.Path }} } }
1 This rule matches the /public
endpoint and forwards the request to our upstream service without performing any verification or transformation.2 This rule matches the /user
and/admin
endpoints, handling both authentication and authorization steps.Since we don’t define a finalizer in the second rule’s pipeline, the default rule’s finalizer is reused. There is no need for additional rules, as the default rule will block requests to any other endpoints. Now that everything related to heimdall configuration is in place, let’s create a policy that OPA will use. Create a file named
policy.rego
with the following contents:package demo default can_access = false (1) can_access { split(input.path, "/")[1] == input.role } (2)
Here, we define our policy
can_access
within thedemo
package. The policy is straightforward, evaluating to either true or false.1 By default, the can_access
policy evaluates to false.2 It evaluates to true only when the last path fragment of the request matches the user’s role. Now, let’s configure NGINX to expose a static endpoint that serves a JWKS document under the
.well-known
path. This will allow heimdall to verify the JWTs we will use. Create a file namedidp.nginx
with the following content:worker_processes 1; user nginx; pid /var/run/nginx.pid; events { worker_connections 1024; } http { keepalive_timeout 65; server { listen 8080; location /.well-known/jwks { default_type application/json; root /var/www/nginx; try_files /jwks.json =404; } } }
In addition, create a file named
jwks.json
containing the public key needed to verify the tokens we will use.{ "keys": [{ "use":"sig", "kty":"EC", "kid":"key-1", "crv":"P-256", "alg":"ES256", "x":"cv6F6SgBSNWMZKdApZXSuPD6QPtvQyMpk-iRfZxT-vo", "y":"C1r3OClUvyDgmDQdvxMdB-ucmZ28b8s4uM4Yg-0BZZ4" }] }
We will place it in the
/var/www/nginx
folder, as mentioned earlier, when we set up our environment.Now, let’s configure the environment. To run heimdall as a proxy, create or modify a
docker-compose.yaml
file. Be sure to update it with the correct paths to yourheimdall-config.yaml
,upstream-rules.yaml
,policy.rego
,idp.nginx
, andjwks.json
files created earlier.services: heimdall: (1) image: dadrus/heimdall:dev ports: - "9090:4456" volumes: - ./heimdall-config.yaml:/etc/heimdall/config.yaml:ro - ./upstream-rules.yaml:/etc/heimdall/rules.yaml:ro - ./signer.pem:/etc/heimdall/signer.pem:ro command: serve proxy -c /etc/heimdall/config.yaml --insecure upstream: (2) image: traefik/whoami:latest command: - --port=8081 idp: (3) image: nginx:1.25.4 volumes: - ./idp.nginx:/etc/nginx/nginx.conf:ro - ./jwks.json:/var/www/nginx/jwks.json:ro opa: (4) image: openpolicyagent/opa:0.62.1 command: run --server /etc/opa/policies volumes: - ./policy.rego:/etc/opa/policies/policy.rego:ro
1 These lines configure heimdall to use our configuration, key store, and rule file, and to run in proxy operation mode. We’re using the --insecure
flag here to simplify our setup, which disables enforcement of some security settings you can learn about more here.2 Here, we configure the "upstream" service, which, as mentioned earlier, is a simple service that echoes everything it receives. 3 This section configures our NGINX service, which mimics an IDP system and exposes a JWKS endpoint with our key material. 4 These lines configure our OPA instance to use the authorization policy. Alternatively, if you prefer to implement EAA with heimdall, create or modify the following
docker-compose-eaa.yaml
file. Be sure to update it with the correct paths to theheimdall-config.yaml
,upstream-rules.yaml
,policy.rego
,idp.nginx
, andjwks.json
files from above.services: proxy: (1) image: traefik:2.11.0 ports: - "9090:9090" command: > --providers.docker=true --providers.docker.exposedbydefault=false --entryPoints.http.address=":9090" --accesslog --api=true --api.insecure=true volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" labels: - traefik.enable=true - traefik.http.routers.traefik_http.service=api@internal - traefik.http.routers.traefik_http.entrypoints=http - traefik.http.middlewares.heimdall.forwardauth.address=http://heimdall:4456 (2) - traefik.http.middlewares.heimdall.forwardauth.authResponseHeaders=Authorization heimdall: (3) image: dadrus/heimdall:dev volumes: - ./heimdall-config.yaml:/etc/heimdall/config.yaml:ro - ./upstream-rules.yaml:/etc/heimdall/rules.yaml:ro - ./signer.pem:/etc/heimdall/signer.pem:ro command: serve decision -c /etc/heimdall/config.yaml --insecure upstream: (4) image: traefik/whoami:latest command: - --port=8081 labels: - traefik.enable=true - traefik.http.services.whoami.loadbalancer.server.port=8081 - traefik.http.routers.whoami.rule=PathPrefix("/") - traefik.http.routers.whoami.middlewares=heimdall idp: (5) image: nginx:1.25.4 volumes: - ./idp.nginx:/etc/nginx/nginx.conf:ro - ./jwks.json:/var/www/nginx/jwks.json:ro opa: (6) image: openpolicyagent/opa:0.62.1 command: run --server /etc/opa/policies volumes: - ./policy.rego:/etc/opa/policies/policy.rego:ro
1 These lines configure Traefik, which is responsible for dispatching incoming requests and forwarding them to heimdall before routing to the target service. We use the ForwardAuth middleware here, which requires additional configuration at the route level. 2 Here we configure Traefik to forward requests to heimdall. 3 These lines configure heimdall to use our configuration, key store, and rule file, and to run in decision operation mode. We’re using the --insecure
flag here to simplify our setup, which disables enforcement of some security settings you can learn about more here.4 Here, we configure the "upstream" service. As previously mentioned, it is a very simple service that just echoes back everything it receives. We also need to provide some route-level configuration here to ensure requests are forwarded to heimdall. While we could have used a global configuration, we decided against it to avoid adding another configuration file. 5 This is our NGINX service, which mimics an IDP system and exposes a JWKS endpoint with our key material. 6 These lines configure our OPA instance to use the authorization policy.
Start Environment
Open your terminal and start the services in the directory where the docker-compose.yaml
file is located:
$ docker compose up
Consume the API
Roll up your sleeves. We’re going to play with our setup now. Open a new terminal window and put it nearby the terminal, you started the environment in. This way you’ll see what is going on in the environment when you use it.
Let’s try the
/public
endpoint first.
$ curl 127.0.0.1:9090/public
+ You should see an output similar to the one shown below:
+
Hostname: 94e60bba8498
IP: 127.0.0.1
IP: 172.19.0.3
RemoteAddr: 172.19.0.4:53980
GET /public HTTP/1.1
Host: upstream:8081
User-Agent: curl/8.2.1
Accept: */*
Accept-Encoding: gzip
Forwarded: for=172.19.0.1;host=127.0.0.1:9090;proto=http
+ That was expected, as we sent a request to our public endpoint.
Now, let’s try some other endpoints:
$ curl -v 127.0.0.1:9090/admin
+
The -v
flag is added to the curl command intentionally. Without it, we won’t see the detailed output. With it, you’ll see the response shown below:
+
* processing: 127.0.0.1:9090/admin
* Trying 127.0.0.1:9090...
* Connected to 127.0.0.1 (127.0.0.1) port 9090
> GET /admin HTTP/1.1
> Host: 127.0.0.1:9090
> User-Agent: curl/8.2.1
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< Date: Wed, 06 Mar 2024 16:14:05 GMT
< Content-Length: 0
<
* Connection #0 to host 127.0.0.1 left intact
+
That is, unauthorized. Requests to any endpoint other than /public
will result in the same output.
Let’s now use a valid JWT to access either the
/admin
or/user
endpoint. Here’s a new request to our/admin
endpoint, which includes a bearer token in theAuthorization
header. This should grant us access:
$ curl -H "Authorization: Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6ImtleS0xIiwidHlwIjoiSldUIn0.eyJleHAiOjIwMjUxMDA3NTEsImlhdCI6MTcwOTc0MDc1MSwiaXNzIjoiZGVtb19pc3N1ZXIiLCJqdGkiOiI0NjExZDM5Yy00MzI1LTRhMWYtYjdkOC1iMmYxMTE3NDEyYzAiLCJuYmYiOjE3MDk3NDA3NTEsInJvbGUiOiJhZG1pbiIsInN1YiI6IjEifQ.mZZ_UqC8RVzEKBPZbPs4eP-MkXLK22Q27ZJ34UwJiioFdaYXqYJ4ZsatP0TbpKeNyF83mkrrCGL_pWLFTho7Gg" 127.0.0.1:9090/admin
+ Now we can access the endpoint and see the following output:
+
Hostname: 94e60bba8498
IP: 127.0.0.1
IP: 172.19.0.2
RemoteAddr: 172.19.0.4:43688
GET /admin HTTP/1.1
Host: upstream:8081
User-Agent: curl/8.2.1
Accept: */*
Accept-Encoding: gzip
Authorization: Bearer eyJhbGciOiJFUzM4NCIsImtpZCI6ImIzNDA3N2ZlNWI5NDczYzBjMmY3NDNmYWQ0MmY3ZDU0YWM3ZTFkN2EiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjE3MTg2MzYwMDAsImlhdCI6MTcxODYzNTcwMCwiaXNzIjoiaGVpbWRhbGwiLCJqdGkiOiIyZjc0MjRmNy05ZWFkLTQ4MzItYmM2Yy0xM2FiNDY5NTNjOTQiLCJuYmYiOjE3MTg2MzU3MDAsInN1YiI6IjEifQ._xy_TRsQpiBPsdGi6gh1IOlyep62YpgxiqquXhg-guVdhpslS4PfVH139dv50GOX0fj3F31q8__8QWWvzPJCEI0aEaaMazIVZ24qjyFM2LJvX0o0ILePxfeDU3bhzN8i
Forwarded: for=172.19.0.1;host=127.0.0.1:9090;proto=http
+ Take a closer look at the JWT echoed by our service, e.g. by making use of jwt.io. This token has been issued by heimdall, not the one you sent with curl.
Now, try the same request to the
/user
endpoint. It will be refused due to the wrong role. Let’s use a different JWT that should grant us access.
$ curl -H "Authorization: Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6ImtleS0xIiwidHlwIjoiSldUIn0.eyJleHAiOjIwMjUxMDA3NTEsImlhdCI6MTcwOTc0MDc1MSwiaXNzIjoiZGVtb19pc3N1ZXIiLCJqdGkiOiIzZmFmNDkxOS0wZjUwLTQ3NGItOGExMy0yOTYzMjEzNThlOTMiLCJuYmYiOjE3MDk3NDA3NTEsInJvbGUiOiJ1c2VyIiwic3ViIjoiMiJ9.W5xCpwsFShS0RpOtrm9vrV2dN6K8pRr5gQnt0kluzLE6oNWFzf7Oot-0YLCPa64Z3XPd7cfGcBiSjrzKZSAj4g" 127.0.0.1:9090/user
+ This should work now. We omitted the output for brevity, but you should see a successful response.
Try sending requests to the
/private
endpoint using any of the tokens from above. It will fail, as heimdall will not allow access.
Cleanup
Once you’re done, stop the environment with CTRL-C
and delete the created files. If you started Docker Compose in the background, tear down the environment with:
$ docker compose down
Last updated on Apr 8, 2025