Skip to content

Proxy Providers

Overview

Proxy Providers let JustIAM act as an authentication and authorisation gate in front of any HTTP application — without modifying the upstream app. Two deployment modes are available:

Mode How it works Use when
forward_auth Traefik sends a sub-request to JustIAM's /proxy/auth endpoint before forwarding the original request. JustIAM returns 200 (pass) or 401/403 (reject). App is on the same domain as JustIAM (shared Traefik)
reverse_proxy A standalone proxy-provider binary sits in front of the upstream. It owns the OIDC session and proxies authenticated requests. App is on a different domain / cluster

In both modes the same Access Policy engine runs: glob path matching, per-method filtering, group/role/attribute conditions, and optional Tengo scripts.


Concepts

Concept Description
Provider A named proxy configuration scoped to one or more hostnames
Binding A hostname registered to a provider (one hostname → one provider)
Access groups Groups that may reach this binding; empty = unrestricted. Checked before per-path policies
Access Policy An ordered rule (priority ASC) — first matching policy wins
Default action Applied per binding when no policy matches: allow, deny, or authenticate
Auto-created OIDC app Each provider gets a private OIDC application (hidden from dashboard) with groups, roles, and attributes claim mappings pre-configured
Cookie secret AES-256-GCM key; dual-key rotation supported for zero-downtime secret changes

Binding access groups

Access groups let you restrict an entire binding to specific groups of users. The gate is enforced by the proxy binary (both forward_auth and reverse_proxy modes) before any per-path policy is evaluated.

Behaviour Result
No groups configured All authenticated users may access the binding (default)
One or more groups configured User must belong to at least one of the listed groups (OR semantics)
User not in any configured group HTTP 403 branded access-denied page is returned immediately

The same group list also controls My Apps portal visibility — users who cannot access a binding will not see it in the portal.

Managing access groups

# List configured groups
GET /api/v1/proxy-providers/{id}/bindings/{binding_id}/access/groups

# Restrict to a group
POST /api/v1/proxy-providers/{id}/bindings/{binding_id}/access/groups
Content-Type: application/json
{ "group_id": "<group-uuid>" }

# Remove a group
DELETE /api/v1/proxy-providers/{id}/bindings/{binding_id}/access/groups/{group_id}

Terraform

resource "justiam_proxy_binding_access_group" "engineering" {
  provider_id = justiam_proxy_provider.myapp.id
  binding_id  = "<binding-uuid>"
  group_id    = justiam_group.engineering.id
}

Multiple groups can be added by creating multiple justiam_proxy_binding_access_group resources.


Access Policies

Policies are evaluated in ascending priority order. The first policy whose path and method filters match wins.

Field Description
path_pattern Doublestar glob matched against the request path. Examples: /health, /api/v1/*, /admin/**, /**
methods Comma-separated HTTP methods (GET, POST, …). Empty = match all methods
action allow — pass unconditionally; deny — return 403; authenticate — require a valid session + conditions
require_any_group OR — user must be in at least one of the listed groups
require_all_groups AND — user must be in every listed group
require_roles OR — user must hold at least one of the listed app-roles
require_attributes AND — all key=value attribute pairs must match
script Optional Tengo expression (must return bool). Receives user, groups, roles, attributes variables

Condition types are combined with AND between types: for a policy to pass, all non-empty condition groups must pass.

Headers injected on 200 (both modes):

Header Value
X-Auth-User User UUID
X-Auth-Email Email address
X-Auth-Name Display name
X-Auth-Groups Comma-separated group names
X-Auth-Roles Comma-separated app-role values

forward_auth mode (same domain)

In this mode no extra binary is needed. Traefik calls GET /proxy/auth on the JustIAM backend before forwarding each request. The IDP session cookie (idp_token) is used for identity.

Traefik Middleware (Kubernetes)

Create one Middleware resource per protected application (or share one across apps on the same ingress class):

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: justiam-forward-auth
  namespace: justiam-mt          # same namespace as your Traefik instance
spec:
  forwardAuth:
    address: "https://idp.example.com/proxy/auth"
    authResponseHeaders:
      - X-Auth-User
      - X-Auth-Email
      - X-Auth-Name
      - X-Auth-Groups
      - X-Auth-Roles
    trustForwardHeader: true     # preserve X-Forwarded-* from the original request

Then annotate the protected Ingress or reference it in an IngressRoute:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp
  namespace: myapp
  annotations:
    traefik.ingress.kubernetes.io/router.middlewares: "justiam-mt-justiam-forward-auth@kubernetescrd"
spec:
  rules:
    - host: myapp.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myapp
                port:
                  number: 8080
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: myapp
  namespace: myapp
spec:
  entryPoints: [websecure]
  routes:
    - match: Host(`myapp.example.com`)
      kind: Rule
      middlewares:
        - name: justiam-forward-auth
          namespace: justiam-mt
      services:
        - name: myapp
          port: 8080

The Kubernetes Gateway API does not natively support forward-auth. Use the reverse_proxy mode with a Gateway HTTPRoute instead, or annotate the underlying Traefik IngressRoute.

Registering the binding

After creating the Middleware, add the hostname as a binding on the proxy provider:

POST /api/v1/proxy-providers/{id}/bindings
Content-Type: application/json

{ "hostname": "myapp.example.com" }

JustIAM uses the X-Forwarded-Host header to look up the provider (30 s TTL cache).


reverse_proxy mode (cross-domain)

In this mode the proxy-provider binary handles the full OIDC authorization-code flow and session management independently of the IDP session cookie. Sessions are stored in AES-256-GCM encrypted cookies tied to the provider's auto-created OIDC application.

Architecture

Browser → [Traefik] → proxy-provider :4180 ──(authenticated)──▶ Upstream app
                              ├─ /__proxy/callback  (OIDC code exchange)
                              ├─ /__proxy/sign-out  (clear cookie + IDP logout)
                              └─ /healthz   (liveness / readiness)

The binary polls GET /api/v1/proxy/v1/config every 30 seconds (configurable) using a service-account PAT with the proxy_providers.config_read permission.

Service Account Setup

  1. Create a service account user in the admin UI (Users → New User → enable "Service Account")
  2. Create a PAT for it with scope proxy_providers.config_read (under Users → select the service account → Tokens)
  3. Copy the token — it will only be shown once

Kubernetes Deployment

The k8s-mt/proxy-provider.yaml manifest in this repository provides a ready-to-use deployment:

apiVersion: v1
kind: Secret
metadata:
  name: justiam-proxy-provider
  namespace: justiam-mt
type: Opaque
stringData:
  JUSTIAM_ISSUER: "https://idp.example.com"   # tenant issuer URL
  JUSTIAM_TOKEN:  "justiam_…"                 # service-account PAT
apiVersion: apps/v1
kind: Deployment
metadata:
  name: proxy-provider
  namespace: justiam-mt
spec:
  replicas: 2      # stateless — scale freely
  # ... (see k8s-mt/proxy-provider.yaml)

Environment variables:

Variable Default Description
JUSTIAM_ISSUER Required. Tenant OIDC issuer URL
JUSTIAM_TOKEN Required. Service-account PAT
LISTEN :4180 Bind address
POLICY_REFRESH_SECS 30 Config poll interval (seconds)
LOG_LEVEL info Log level (debug, info, warn, error)

Traefik HTTPRoute + ForwardAuth

For cross-domain apps that still go through Traefik, use forward_auth pointing to the proxy-provider binary:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: proxy-provider-auth
  namespace: justiam-mt
spec:
  forwardAuth:
    address: "http://proxy-provider.justiam-mt.svc:4180"
    authResponseHeaders:
      - X-Auth-User
      - X-Auth-Email
      - X-Auth-Name
      - X-Auth-Groups
      - X-Auth-Roles
    trustForwardHeader: true

Kubernetes Gateway API (HTTPRoute)

When using the Kubernetes Gateway API directly (e.g. with Traefik as a Gateway controller), route traffic through the proxy-provider Service:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: myapp
  namespace: myapp
spec:
  parentRefs:
    - name: main-gateway
      namespace: traefik
  hostnames:
    - "myapp.example.com"
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        # Route to proxy-provider first; it proxies to the upstream after auth.
        - name: proxy-provider
          namespace: justiam-mt
          port: 4180

The proxy-provider handles the /__proxy/callback and /__proxy/sign-out paths automatically. Register myapp.example.com as a binding on the provider and set the mode to reverse_proxy.


Quick-start example

# 1. Create the provider
curl -X POST https://idp.example.com/api/v1/proxy-providers \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My App",
    "mode": "forward_auth",
    "session_duration_secs": 3600
  }'
# → returns { "provider": {...}, "client_id": "...", "client_secret": "..." }
# Save client_id and client_secret — the secret is shown only once.

# 2. Bind a hostname (default_action is per-binding, default: authenticate)
curl -X POST https://idp.example.com/api/v1/proxy-providers/{id}/bindings \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "hostname": "myapp.example.com", "default_action": "authenticate" }'
# → returns { "id": "<binding_id>", "provider_id": "...", "hostname": "...", "default_action": "..." }

# 3. Allow /health unauthenticated (priority 1), require auth for everything else (priority 100)
curl -X POST https://idp.example.com/api/v1/proxy-providers/{id}/bindings/{binding_id}/policies \
  -H "Authorization: Bearer $TOKEN" \
  -d '{ "name": "health", "priority": 1, "path_pattern": "/health", "action": "allow" }'

curl -X POST https://idp.example.com/api/v1/proxy-providers/{id}/bindings/{binding_id}/policies \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "name": "catch-all",
    "priority": 100,
    "path_pattern": "/**",
    "action": "authenticate",
    "require_any_group": ["engineering", "ops"]
  }'

Terraform

resource "justiam_proxy_provider" "myapp" {
  name                  = "My App"
  mode                  = "forward_auth"
  session_duration_secs = 3600
}

# The auto-created OIDC app credentials are exposed as computed attributes:
output "client_id"     { value = justiam_proxy_provider.myapp.client_id }
output "client_secret" {
  value     = justiam_proxy_provider.myapp.client_secret
  sensitive = true
}

resource "justiam_access_policy" "health" {
  provider_id  = justiam_proxy_provider.myapp.id
  binding_id   = "<binding-uuid>"  # UUID from AddBinding response
  name         = "health check"
  priority     = 1
  path_pattern = "/health"
  action       = "allow"
}

resource "justiam_access_policy" "catch_all" {
  provider_id       = justiam_proxy_provider.myapp.id
  binding_id        = "<binding-uuid>"
  name              = "require engineering or ops"
  priority          = 100
  path_pattern      = "/**"
  action            = "authenticate"
  require_any_group = ["engineering", "ops"]
}

# Domain roles — grant a role to a group for use in require_app_mapping policies
resource "justiam_proxy_binding_role" "admin_role" {
  provider_id = justiam_proxy_provider.myapp.id
  binding_id  = "<binding-uuid>"
  role_name   = "admin"
  description = "Admin users on myapp.example.com"
  group_ids   = ["<group-uuid>"]
}

# Restrict the binding to a specific group (HTTP-level gate + portal visibility)
resource "justiam_proxy_binding_access_group" "engineering_only" {
  provider_id = justiam_proxy_provider.myapp.id
  binding_id  = "<binding-uuid>"
  group_id    = "<engineering-group-uuid>"
}

Domain Roles

Domain roles allow fine-grained role-based access within a proxy provider. Each role is scoped to a specific binding (hostname) and can be assigned to one or more groups.

How it works

  1. Create a role on a binding via POST /proxy-providers/{id}/roles (specifying the binding_id).
  2. Assign groups to the role via POST /proxy-providers/{id}/roles/{role_id}/groups.
  3. Reference the role in an access policy using require_app_mapping: ["role_name"].

When the proxy evaluates a request, it checks whether the authenticated user is in any group that has been assigned the required role on that binding. No OIDC claim is needed — the check is done inline by the proxy engine.

Role vs. Group

Groups Domain Roles
Scope Tenant-wide Per binding (hostname)
Evaluated via require_any_group / require_all_groups require_app_mapping
Use case Coarse-grained access (who can enter the app) Fine-grained in-app permissions (admin vs. viewer)

API

Method Path Description
GET /proxy-providers/{id}/roles List all roles (paginated; domain= and q= filters)
POST /proxy-providers/{id}/roles Create a role (requires binding_id + role_name)
DELETE /proxy-providers/{id}/roles/{role_id} Delete a role
POST /proxy-providers/{id}/roles/{role_id}/groups Assign a group to the role
DELETE /proxy-providers/{id}/roles/{role_id}/groups/{group_id} Remove a group from the role

Portal Access Groups

Each binding can optionally be restricted in the My Apps portal to specific groups. When at least one access group is configured, only members of those groups see the binding in the portal. Users who pass access policy checks can still reach the upstream application regardless of portal visibility.

Method Path Description
GET /proxy-providers/{id}/bindings/{binding_id}/access/groups List access groups for a binding
POST /proxy-providers/{id}/bindings/{binding_id}/access/groups Add a group (body: {"group_id":"<uuid>"})
DELETE /proxy-providers/{id}/bindings/{binding_id}/access/groups/{group_id} Remove a group

API Reference

All endpoints require a Bearer token and the proxy_providers.manage permission.

Providers

Method Path Description
GET /proxy-providers List all providers
POST /proxy-providers Create a provider — returns { provider, client_id, client_secret }
GET /proxy-providers/{id} Get a provider
PATCH /proxy-providers/{id} Update a provider
DELETE /proxy-providers/{id} Delete a provider (cascades bindings + policies)

Bindings

Method Path Description
GET /proxy-providers/{id}/bindings List hostname bindings (paginated; q= filter)
POST /proxy-providers/{id}/bindings Add a hostname binding (accepts optional default_action)
PATCH /proxy-providers/{id}/bindings/{hostname} Update binding default action, portal settings, or cleanup config
DELETE /proxy-providers/{id}/bindings/{hostname} Remove a hostname binding

Portal Access Groups (per binding)

Method Path Description
GET /proxy-providers/{id}/bindings/{binding_id}/access/groups List access groups
POST /proxy-providers/{id}/bindings/{binding_id}/access/groups Add access group
DELETE /proxy-providers/{id}/bindings/{binding_id}/access/groups/{group_id} Remove access group

Access Policies

Policies are scoped to a specific binding. The binding_id is returned when adding a binding.

Method Path Description
GET /proxy-providers/{id}/policies List all policies across all bindings (paginated; domain= + q= filters)
GET /proxy-providers/{id}/bindings/{binding_id}/policies List policies for a specific binding
POST /proxy-providers/{id}/bindings/{binding_id}/policies Create a policy for a binding
GET /proxy-providers/{id}/policies/{policy_id} Get a policy
PATCH /proxy-providers/{id}/policies/{policy_id} Update a policy
DELETE /proxy-providers/{id}/policies/{policy_id} Delete a policy

Domain Roles

Method Path Description
GET /proxy-providers/{id}/roles List all roles (paginated; domain= + q= filters)
POST /proxy-providers/{id}/roles Create a role (binding_id + role_name required)
DELETE /proxy-providers/{id}/roles/{role_id} Delete a role
POST /proxy-providers/{id}/roles/{role_id}/groups Assign a group to a role
DELETE /proxy-providers/{id}/roles/{role_id}/groups/{group_id} Remove a group from a role

Forward-auth endpoint

Method Path Permission Description
GET /proxy/auth none Forward-auth sub-request handler (called by Traefik)

The endpoint reads X-Forwarded-Host, X-Forwarded-Uri, and X-Forwarded-Method headers. Returns 200 with X-Auth-* headers on success, 401 + Location to trigger login, or 403 for denied requests.


Binding Cleanup Config

Each binding can declare additional client-side state to clear when the proxy forces a re-authentication (e.g. on session expiry, revocation, or group change). This is configured via PATCH /proxy-providers/{id}/bindings/{binding_id}:

Field Type Description
downstream_cookies []string Regex patterns matched against cookie names. Matching cookies are cleared server-side (via Set-Cookie: name=; MaxAge=-1) on forced re-auth. Supports HttpOnly cookies (e.g. JSESSIONID\..+).
downstream_storage_keys []string localStorage keys to clear client-side when the cleanup page is loaded.
reauth_redirect_url string Optional. After a successful OIDC re-authentication, redirect the user to this URL instead of the page they originally requested. Must be an absolute URL on the same hostname as the binding. Use the {path} token to pass the original path to the app (e.g. https://myapp.example.com/login?return_url={path}). Leave empty (default) to return the user to their original URL.

Preserving the original path with {path}

Use {path} anywhere in the URL to have it replaced with the URL-encoded path (and query string) of the original request. This lets downstream apps like ArgoCD or Kargo redirect the user back to the exact page they were trying to reach after their own login handshake completes.

Example — ArgoCD supports ?return_url= on its login page:

https://argo-cd.example.com/auth/login?return_url={path}
A user hitting /user-info is redirected to:
https://argo-cd.example.com/auth/login?return_url=%2Fuser-info

Regex patterns for cookies

Patterns are anchored (^(?:pattern)$) and matched with Go's regexp package. Use JSESSIONID\..+ to clear any JSESSIONID.<suffix> cookie, including HttpOnly ones that are invisible to JavaScript.

When to use reauth_redirect_url

Some apps (Spring Boot, ArgoCD, Grafana) keep their own internal session that needs to be initialised via a specific login endpoint before the user can reach the original page. Setting reauth_redirect_url to that endpoint (e.g. https://myapp.example.com/login) ensures the app's session is created cleanly after every proxy-level re-authentication. Combine with {path} to preserve deep-link navigation.

Example — full cleanup config with post-reauth redirect:

curl -X PATCH https://idp.example.com/api/v1/proxy-providers/{id}/bindings/{binding_id} \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "downstream_cookies": ["JSESSIONID\\..+", "MY_APP_SESSION"],
    "downstream_storage_keys": ["oidc.user", "app.state"],
    "reauth_redirect_url": "https://myapp.example.com/login?return_url={path}"
  }'

Cleanup Debug Page

The proxy-provider binary exposes a debug page at /__proxy/cleanup on the protected hostname.

Read-only view

Visiting /__proxy/cleanup shows:

  • Configured cookie patterns — the regex patterns registered on the binding
  • Current cookies — lists each configured pattern and whether a matching cookie is JS-readable or HttpOnly
  • Current localStorage — lists configured keys and their current values

Test server-side clearing (?test=1)

Clicking "Run cleanup now" (or navigating to /__proxy/cleanup?test=1) triggers a server-side test run:

  1. The binary reads all cookies on the request (including HttpOnly ones invisible to JS).
  2. Cookies matching the configured patterns are expired via Set-Cookie: name=; Path=/; MaxAge=-1.
  3. The page redirects back to /__proxy/cleanup with a result banner:
  4. Green: lists cookies that were cleared.
  5. Amber warning: lists configured patterns for which no matching cookie was found (they may already be absent or have a different name).

Session Revocation

Automatic revocation on app-mapping changes

When a group app-mapping is created, updated, or deleted — or when groups are assigned to / removed from an app-mapping — the proxy provider automatically revokes the proxy sessions for all affected group members. This forces those users to re-authenticate on their next request, at which point the new (or removed) mapping values take effect.

Revocation uses a per-group, per-binding strategy so that only users who actually belong to the changed group, and only on the specific binding linked to the affected application, are forced to re-authenticate. Sessions on unrelated bindings (e.g. ArgoCD) are not touched when a Kargo mapping changes.

Linking a binding to an application

For scoped revocation to work, each binding must declare which OIDC application owns its app-mappings via linked_application_id. Without a link the binding is not affected by any app-mapping change.

PATCH /api/v1/proxy-providers/{id}/bindings/{hostname}
Content-Type: application/json

{ "linked_application_id": "<application-uuid>" }

To clear the link send an empty string:

{ "linked_application_id": "" }

You can also set it from the UI: Proxy Providers → Hostnames → Settings (⚙) → Linked application.

How revocation works

  1. An admin creates / updates / deletes an app-mapping entry, or adds / removes a group from one.
  2. The backend identifies the changed group and the affected mapping.
  3. It upserts a row in proxy_binding_group_revocations(binding_id, group_id, revoked_at) for every binding whose linked_application_id matches the mapping's application_id.
  4. The proxy-provider binary observes the config change event stream (SSE), re-fetches the config, and from that point rejects any session whose issued_at is older than the stored revoked_at and whose login-time groups include the revoked group.
  5. The affected user is sent through a full OIDC re-authentication and receives a fresh token with updated mapping values.

Revocation is asynchronous: in-memory sessions are invalidated within the configured poll interval (default 30 s).

Manual revocation

An admin can also revoke all sessions for a specific user:

curl -X POST https://idp.example.com/api/v1/proxy-providers/{id}/revoke \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "user_id": "<uuid>" }'

Built-in service account

Each tenant has a built-in service account used exclusively by the control-plane to provision and rotate the proxy PAT:

Field Value
Email proxy-provider@service.internal
UUID 00000000-0000-0000-0000-000000000004
Role Proxy Config Reader (grants proxy_providers.config_read)

This account is created automatically by database migration 024. It has no password and cannot authenticate interactively.

Deletion protection: The account cannot be deleted while the tenant's proxy_mode is shared or dedicated. The API returns 409 Conflict in that case. Switch proxy_mode to none first if you need to remove it.