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 the user role.

  • The /admin endpoint should only be accessible to users with the admin 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:

Configure

  1. 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:

    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
    1Since heimdall emits logs at the error level by default, and we want to monitor what’s happening, we’ll set the log level to debug. This way, we’ll not only see the results of a particular rule execution (which you would see with the info 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.
    2This 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.
    3Here, we define our catalogue of mechanisms to be used in upstream service-specific rules. In this case, we define authenticators, an authorizer, and finalizers.
    4These two lines define the unauthorized authenticator named deny_all, which rejects all requests.
    5These two lines define the anonymous authenticator named anon, which allows any request and creates a subject with the ID set to anonymous. You can find more information about the subject and other objects here.
    6This and the following lines define and configure the jwt authenticator named jwt_auth. With this configuration, it will check if a request contains an Authorization 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 the sub claim set to the token’s sub value. All other claims will also be added to the subject’s attributes.
    7Here, we define and configure a remote authorizer named opa. Note how we allow for the overriding of particular settings, which will be specified below when we define the rules.
    8The following lines define the jwt finalizer. This configuration will generate a JWT from the subject object with standard claims, setting the sub claim to the subject’s ID. The key material used for signing is pulled from the referenced key store.
    9These two lines conclude the definition of our mechanisms catalogue and define the noop finalizer, which, as the name implies, does nothing.
    10With 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 the jwt finalizer.
    11The 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.
  2. 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!
  3. 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 }}
              }
            }
    1This rule matches the /public endpoint and forwards the request to our upstream service without performing any verification or transformation.
    2This 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.
  4. 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 the demo package. The policy is straightforward, evaluating to either true or false.

    1By default, the can_access policy evaluates to false.
    2It evaluates to true only when the last path fragment of the request matches the user’s role.
  5. 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 named idp.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.

  6. 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 your heimdall-config.yaml, upstream-rules.yaml, policy.rego, idp.nginx, and jwks.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
    1These 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.
    2Here, we configure the "upstream" service, which, as mentioned earlier, is a simple service that echoes everything it receives.
    3This section configures our NGINX service, which mimics an IDP system and exposes a JWKS endpoint with our key material.
    4These lines configure our OPA instance to use the authorization policy.
  7. 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 the heimdall-config.yaml, upstream-rules.yaml, policy.rego, idp.nginx, and jwks.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
    1These 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.
    2Here we configure Traefik to forward requests to heimdall.
    3These 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.
    4Here, 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.
    5This is our NGINX service, which mimics an IDP system and exposes a JWKS endpoint with our key material.
    6These 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.

  1. 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.

  1. 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.

  1. 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 the Authorization 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.

  1. 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.

  1. 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