Securing the Traefik Dashboard/API with Cloudflare Access

I recently setup a new cloud VPS for this blog, couple of apps, and a sites for my business. I decided to use Traefik Proxy to help orchestrate application deployments better than manually configuring Apache httpd. Traefik also integrates nicely with Docker, making it super simple to get everything working the wait I want. There is a built-in dashboard that comes with Traefik that gives you a quick overview of what's going on, although it isn't secured by default. There are a number of options to enable secure access with middleware, like HTTP Basic authentication or IP address whitelisting. Those methods feel so 2000s to me, and I knew there was a better way. Enter, Cloudflare Access.

Cloudflare Access is a Zero-Trust access control solution for authenticating connections to internal/corporate web applications. It's also way way faster than connecting with a VPN. I can also leverage my Google Workspace domain as an identity provider for Access.

To authenticate requests to the Traefik dashboard, we'll be using a ForwardAuth middleware. This middleware makes an HTTP request to an external service with headers from the original request. If the authentication service returns an HTTP 2xx status code, access is allowed. Otherwise, Traefik sends the response from the service back and access is denied.

Cloudflare helpfully includes a Cf-Access-Jwt-Assertion header with every request, which is what I'll use to validate access to Traefik. This token is generated when you login to Cloudflare Teams/Access with your configured identity provider. Cloudflare provides a way to validate these tokens at the origin and all I need is a simple Python Flask server that consumes the JWT header, fetches the signing keys, and validates the token.

image: Cloudflare

Validating the JWT

Cloudflare provides some sample Python code to validate the JWT, which can be adapted into a simple method that takes the JWT from the HTTP header and the audience tag generated for your Access Application.

import jwt

TEAMS_DOMAIN='test.cloudflareaccess.com'

def validate_jwt(token, aud):
    jwks_client = jwt.PyJWKClient('{}/cdn-cgi/access/certs'.format(TEAMS_DOMAIN))
    signing_key = jwks_client.get_signing_key_from_jwt(token)
    try:
        jwt.decode(token, signing_key.key, ["RS256"], options={'require':['exp', 'iat', 'email']}, audience=aud, issuer='https://'+TEAMS_DOMAIN)
        return True
    except:
        return False

Configure Traefik

api:
  dashboard: true

http:
  middlewares:
    cloudflare-access:
      forwardAuth:
        address: "http://traefik-forwardauth-cloudflare-access:8000/auth/:AUD"
        authRequestHeaders:
          - "Accept"
          - "Cf-Access-Jwt-Assertion"
Replace :AUD with your application's audience tag from the Teams dashboard.

Setup Access

  1. Create a proxied A, AAAA, or CNAME record that points to the public IP of the server.
  2. Add a new Access application in Cloudflare for Teams dashboard.
  3. Copy the AUD tag.

Docker Container

Here's a sample docker-compose.yml file that enables the middleware and uses a docker image I created.

services:
  traefik:
    image: "traefik:2"
    ports: ["443:443"]
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.api.rule=Host(`traefik.example.com`)"
      - "traefik.http.routers.api.service=api@internal"
      - "traefik.http.routers.api.entrypoints=https"
      - "traefik.http.routers.api.middlewares=cloudflare-access@file"
  traefik-forwardauth-cloudflare-access:
    image: ghcr.io/byte-method/traefik-forwardauth-cloudflare-teams:latest
    environment:
      CF_TEAMS_DOMAIN: "test.cloudflareaccess.com"
Byte-Method/traefik-forwardauth-cloudflare-teams
Traefik ForwardAuth server to verify Cloudflare Access JWT tokens. - Byte-Method/traefik-forwardauth-cloudflare-teams

Testing

To test that the configuration is working, I'll attempt to connect directly to the public IP of the server:

$ curl -ksvo /dev/null https://traefik.example.com/dashboard/ --connect-to ::192.0.2.1 2>&1 | egrep -i "< location|< http"
< HTTP/2 401

Great! The request is denied (HTTP 401 Unauthorized). I should get a login page if I try to access that domain in a web browser.