OAuth2 Authentication Template with Group Checks using OAuth2 Proxy and Self-Hosted OIDC

Hi everyone,

I’ve been working on a Caddy (v2.10.0) setup for OAuth2 authentication using OAuth2 Proxy as middleware and a self-hosted OIDC provider (like Pocket ID for group claims). I couldn’t find many complete, working examples online, so after some trial-and-error, I built this. I’m sharing a genericized and simplified version focused on the auth side to help others get started. This works well with Caddy v2+ and could integrate with v3’s new config tools for validation.

This template uses Caddy snippets for reusability: cookie checks, forward auth to OAuth2 Proxy, group verification via headers, and error handling. It assumes you’re running OAuth2 Proxy separately and configured to pass group info in the X-Auth-Request-Groups header.

Key Features

  • Redirects unauthenticated users to OAuth2 Proxy sign-in.
  • Forward auth validates sessions and groups.
  • Regex-based group checks (case-insensitive) for role-based access.
  • Custom error responses and handling.
  • Examples for group-protected sites and a special case with public paths.

Genericized Caddyfile

# Core Auth Snippets

(auth_error_handler) {
	handle_errors {
		@unauthenticated expression `{http.error.status_code} == 401`
		redir @unauthenticated https://{oauth_proxy_host}/oauth2/sign_in?rd=https://{host}{uri} 302
		respond "Service is currently unavailable. Please try again later." 503
	}
}

(auth_cookie_check) {
	@noauth not header Cookie *_oauth2_proxy=*
	redir @noauth https://{oauth_proxy_host}/oauth2/sign_in?rd=https://{host}{uri} 302
}

(auth_forward) {
	forward_auth {oauth_proxy_host:port} {
		uri /oauth2/auth
		copy_headers X-Auth-Request-Groups
		header_up X-Auth-Request-Redirect https://{host}{uri}
	}
}

(auth_group_check) {
	@no_groups not header X-Auth-Request-Groups *
	respond @no_groups "Access denied: No group membership found." 403
	@allowed header_regexp X-Auth-Request-Groups (?i)\b{args[0]}\b
}

(auth_deny_response) {
	respond "Access denied: You must be in the '{args[0]}' group." 403
}

(simple_error_handler) {
	handle_errors {
		respond "Something went wrong." 500
	}
}

# OAuth2 Proxy Itself (Unauthenticated, as it's the auth entrypoint)
{oauth_proxy_domain} {
	reverse_proxy {oauth_proxy_internal_url}  # e.g., http://localhost:4180
	import simple_error_handler
}

# Example: Protected Site for "infra" Group
infra-example.{your_domain} {
	import auth_error_handler
	import auth_cookie_check
	import auth_forward
	import auth_group_check infra
	handle @allowed {
		reverse_proxy {protected_service_url}  # e.g., https://internal-service:8006
	}
	import auth_deny_response infra
	import simple_error_handler
}

# Example: Protected Site for "all_team" Group
team-example.{your_domain} {
	import auth_error_handler
	import auth_cookie_check
	import auth_forward
	import auth_group_check all_team
	handle @allowed {
		reverse_proxy {protected_service_url}  # e.g., http://internal-service:9182
	}
	import auth_deny_response all_team
	import simple_error_handler
}

# Example: Protected Site with Custom Path Handling (e.g., Public FAQ with Auth Fallback)
special-example.{your_domain} {
	import auth_error_handler
	route {
		@public_path path /public/api/path  # Adjust to your public paths
		handle @public_path {
			reverse_proxy {public_service_url}  # Allow unauth access to specific paths
		}
		import auth_cookie_check
		import auth_forward
		import auth_group_check restricted_group
		handle @allowed {
			reverse_proxy {protected_service_url}
		}
		import auth_deny_response restricted_group
	}
	import simple_error_handler
}

How the Snippets Are Applied

To make this clearer, here’s a breakdown of how the snippets are applied in the “infra-example.{your_domain}” site configuration. This modular approach lets you reuse auth logic across multiple sites without repetition.

  1. Import Error Handling (import auth_error_handler): This catches auth-related errors (e.g., 401 from failed forward auth) and redirects to sign-in or shows a 503 for other issues, preventing users from seeing raw errors.

  2. Check for Auth Cookie (import auth_cookie_check): If no OAuth2 cookie is present, redirect to the sign-in page. This is the first gatekeeper.

  3. Forward Auth to OAuth2 Proxy (import auth_forward): Sends a subrequest to OAuth2 Proxy’s /oauth2/auth endpoint. It copies group headers back and sets the redirect URI, allowing the proxy to validate the session and enrich the request with user groups.

  4. Group Check (import auth_group_check infra): Uses the passed group argument (“infra”) to define matchers. It checks if groups exist (deny if not) and if the regex matches the required group (case-insensitive). The @allowed matcher triggers the next handle.

  5. Handle Allowed Requests (handle @allowed { reverse_proxy ... }): If groups match, proxy to the upstream service. Customize the reverse_proxy with your service URL, transports, etc.

  6. Deny Response (import auth_deny_response infra): If not allowed, show a custom 403 message with the group name.

  7. Simple Error Handler (import simple_error_handler): Fallback for non-auth errors, like upstream failures.

This structure ensures a consistent auth flow: cookie check → forward auth → group verify → proxy or deny. For the other examples, it’s similar—team-example uses “all_team” group, and special-example adds route-based public paths before applying auth.

Quick Explanation

  • Auth Flow: No cookie? Redirect to sign-in. Forward auth to OAuth2 Proxy, which validates and adds group headers. Check groups with regex—if matched, proxy to the service; else, deny.
  • Customization: Replace placeholders (e.g., {oauth_proxy_host:port}). For self-signed upstreams, add transport http { tls_insecure_skip_verify }. Configure OAuth2 Proxy to fetch groups from your OIDC provider.
  • Pitfalls from My Experience: Watch for redirect loops (ensure cookie domains match), verify group headers in OIDC claims, and test incrementally.

Diagram for Visualization

Here is diagrams to illustrate the flow.

Sequence Diagram for Forward Auth:

I’ve also put this in a GitHub repo for easier forking: Caddy OAuth2 Proxy Authentication Template with Group Checks. Feedback, improvements, or questions welcome.

3 Likes

This is great! Thank you for sharing :heart:

Just out of curiosity, have you tried TinyAuth with Pocket-ID ?

Thanks! TinyAuth looks promising, and I considered it but didn’t dive too deeply at the time. Before switching to OAuth2 Proxy and Pocket ID with Caddy, I was using mTLS to secure access to team resources. It worked fine initially, but as the team grew, some (especially those on Apple devices) struggled with adding mTLS certificates. Plus, managing the CA and certificates was getting unwieldy, and technical hurdles for non-technical users were becoming a bigger issue.

I wanted to enforce RBAC based on group memberships in Pocket ID. Now, OAuth2 Proxy pulls the user’s group info and passes it in headers, which Caddy checks to grant or deny access. This lets a single OAuth2 Proxy instance act as middleware for multiple services and groups. Pretty flexible.

I didn’t think TinyAuth could handle group-based checks like that, but I followed the links that you shared and see now that it can enforce access using group scopes and Docker labels on a per-app basis. If I understand correctly, you can set the tinyauth.oauth.groups label on individual containers to require specific groups for each protected service, thereby matching requests by subdomain or custom domain labels, and all handled by one TinyAuth instance? So no need for multiple instances (like I thought) unless you’re isolating things further? If so, that’s pretty neat and similar to my setup’s flexibility. Have you tried it for multi-service RBAC scenarios? Interested in your thoughts.

Maybe Tinyauth wouldn’t be a good fit though… most of my services are Docker-based, but some key ones aren’t containerized. Plus, many of my apps run on Umbrel, and I’d be reluctant to change their container settings to add TinyAuth labels. Further, some are set up with Portainer (which is itself an Umbrel app), involving docker-in-docker, which might complicate things. From what I gather, TinyAuth’s per-service group enforcement relies on reading Docker labels from the protected containers (via Docker API access), so for non-Docker upstreams or without modifying existing containers, it might not support varying group requirements the same way without workarounds. Have you tried it in a mixed Docker/bare-metal setup like that, or with Umbrel/Portainer?

1 Like