Product documentation
Application Proxy / ZTNA integration (replace Entra Application Proxy)
Publish an on-prem or internal web app behind a Zero-Trust-Network-Access product — Cloudflare Access, Pomerium, or any OIDC-capable ZTNA — with Thoryn as the OIDC identity provider. The pattern that replaces Microsoft Entra Application Proxy.
Application Proxy / ZTNA integration
Microsoft Entra Application Proxy publishes an internal web app to the internet and enforces sign-in in front of it: an outbound-only connector agent runs inside your VPC, and Entra pre-authenticates every request before it reaches the app. That is two products fused together — a network reverse proxy and an identity provider.
Thoryn is the identity half. It is a pure OAuth 2.0 / OIDC identity broker, not a network
proxy, so it does not ship a connector agent (ADR
2026-06-01-endpoint-os-integration-strategy.md). To replace Entra Application Proxy you pair
Thoryn with a Zero-Trust-Network-Access (ZTNA) product that provides the proxy, and point
that product's OIDC login at Thoryn:
Browser
↓ HTTPS
ZTNA proxy (Cloudflare Access / Pomerium / …) ← network reverse proxy + policy enforcement
↓ OIDC Authorization Code + PKCE
Thoryn Authorization Hub ← the OIDC identity provider (tokens only)
↓ connector / tunnel (the ZTNA product's own)
Your internal web app ← never exposed directly to the internet
The ZTNA product owns the network path and the access policy; Thoryn owns authentication. Because the seam between them is standard OIDC, any OIDC-capable ZTNA product integrates with no Thoryn-side code changes — it registers as an ordinary OAuth client and follows Thoryn's discovery document.
This guide covers the general pattern first, then two concrete reference integrations: Cloudflare Access (SaaS) and Pomerium (open-source, self-hosted in your VPC).
What this replaces — and what it does not. This closes the Entra Application Proxy gap for web apps. It does not publish a raw TCP / LDAP / Kerberos surface, and it does not deliver Windows Primary-Refresh-Token silent SSO — those are separate decisions in the same ADR. If your legacy apps are web-only, the ZTNA + Thoryn-as-IdP pattern closes the gap cleanly.
The general pattern (any OIDC-capable ZTNA)
Every ZTNA product that speaks OpenID Connect needs the same five things from its IdP. All of them come from Thoryn.
1. Register the ZTNA proxy as an application
The ZTNA proxy is an OAuth confidential client of your tenant. Register it exactly like any other application — through the console → Applications page, or the management API. Its redirect URI is the callback URL the ZTNA product tells you to use (each product has a fixed callback shape; see the product sections below).
The essentials — full detail in Integrate an application:
- Client type:
confidential(the proxy is a server-side component that can hold a secret). A secret is minted once on creation. - Grant types:
authorization_code(plusrefresh_tokenif the proxy maintains a refreshable session). - Redirect URI: the proxy's OIDC callback URL. It is matched exactly at
/oauth2/authorize— trailing slash and case included — so paste the value the ZTNA product gives you verbatim. - Scopes:
openidis mandatory (it is what makes the flow OIDC and yields an ID token). Addprofileandemailso the proxy can display and policy-match on the user's name and email, andoffline_accessonly if the proxy asks for a refresh token.
POST /api/v1/applications
Authorization: Bearer <tenant-admin token>
Content-Type: application/json
{
"displayName": "Cloudflare Access — intranet",
"redirectUris": ["https://<your-team>.cloudflareaccess.com/cdn-cgi/access/callback"],
"scopes": ["openid", "profile", "email"],
"grantTypes": ["authorization_code"],
"clientType": "confidential"
}Record the returned clientId and the once-shown clientSecret — you paste both into the
ZTNA product.
2. Point the ZTNA proxy at the tenant discovery document
Each Thoryn tenant is its own OIDC issuer (ADR
2026-05-20-multi-provider-federation-runtime.md, and the multi-issuer model in CLAUDE.md):
- Named tenant:
https://{slug}.hub.<platformDomain> - Default tenant:
https://hub.<platformDomain>
<platformDomain> is your deployment's domain (staging is concrete:
https://hub.stg.thoryn.org). Always configure the ZTNA product with the discovery URL,
not hand-typed endpoints:
https://{slug}.hub.<platformDomain>/.well-known/openid-configuration
The discovery document advertises issuer, authorization_endpoint, token_endpoint,
userinfo_endpoint, and jwks_uri — all rewritten to the tenant's own host — so a proxy
that reads it is pointed at the right per-tenant endpoints automatically and validates tokens
against the right issuer and keys. A ZTNA product that only accepts a bare "issuer URL" (and
derives the rest) takes the issuer value above; it appends /.well-known/openid-configuration
itself.
3. Required scopes and claims
| Scope | Why the proxy needs it |
|---|---|
openid | Required. Makes the request an OIDC request; yields the ID token the proxy validates to establish the session. |
profile | The user's display name (name, preferred_username) for the proxy's session UI and logs. |
email | The email claim — most ZTNA policies match on email or email domain. |
offline_access | Only if the proxy keeps a long-lived, refreshable session (issues a refresh_token). |
The ID token carries the standard OIDC subject (sub) and the profile/email claims above.
Most ZTNA products let you map an identity claim onto their internal user identity and a
groups claim onto policy groups:
- User identity: map to
email(orpreferred_username). Usesubwhen you need a stable, opaque, non-reassignable identifier —emailcan change. - Groups / roles: if your access policies are group-based, enrich the token with a
groupsororg_rolesclaim and map the proxy's "groups" field to it. See Claims in the token for how to add group and role claims (they are not emitted by default — request them explicitly).
4. The end-to-end flow
Once registered and pointed at discovery, the runtime flow is standard Authorization Code + PKCE (Integrate an application):
- An unauthenticated user hits the published app; the ZTNA proxy intercepts and redirects the
browser to Thoryn's
authorization_endpoint. - The user authenticates at Thoryn (via the tenant's own login or a federated IdP — the proxy neither sees nor cares which).
- Thoryn redirects back to the proxy's callback with an authorization
code; the proxy exchanges it at thetoken_endpointusing itsclient_id/client_secret. - The proxy validates the ID token against
jwks_uri, applies its own access policy, and — on allow — forwards the request to the internal app over its connector/tunnel.
Thoryn's job ends at step 3. Steps 1 and 4 (interception, tunnelling, policy) are the ZTNA product's.
Cloudflare Access (SaaS)
Cloudflare Access is the SaaS reference integration. It uses a generic OIDC login method — you do not need a Cloudflare-specific Thoryn feature.
1. Register the client in Thoryn
Cloudflare Access's OIDC callback URL is fixed to your team domain:
https://<your-team-name>.cloudflareaccess.com/cdn-cgi/access/callback
Register a confidential application (per the general pattern)
with that URL as the sole redirect URI, openid profile email scopes, and
authorization_code. Keep the clientId and once-shown clientSecret.
2. Add Thoryn as a generic OIDC login method
In the Cloudflare Zero Trust dashboard → Settings → Authentication → Login methods → Add new → OpenID Connect. Configure:
| Cloudflare field | Value |
|---|---|
| Name | Thoryn (shown on the login page) |
| App ID / Client ID | the Thoryn clientId |
| Client secret | the Thoryn clientSecret (shown once at registration) |
| Auth URL | https://{slug}.hub.<platformDomain>/oauth2/authorize |
| Token URL | https://{slug}.hub.<platformDomain>/oauth2/token |
| Certificate (JWKS) URL | https://{slug}.hub.<platformDomain>/oauth2/jwks |
| OIDC scopes | openid, profile, email |
Read these four URLs off the tenant's /.well-known/openid-configuration rather than typing
them — the discovery document is authoritative and always carries the tenant-correct host.
Enable Proof of Key Exchange (PKCE) if the option is offered.
To carry roles into Cloudflare policies, add an OIDC claim (e.g. groups) under the login
method, and request that claim in the token (see Claims in the token).
3. Publish the app and require Thoryn
- Create a Self-hosted Access application for the internal app's public hostname (fronted
by a Cloudflare Tunnel /
cloudflaredconnector into your VPC — Cloudflare's own network layer, outside Thoryn's scope). - In the application's policies, set the identity provider to the Thoryn login method
and add a rule (e.g. emails ending in
@acme.com, or agroupsmatch).
4. Test end-to-end
Open the app's hostname in a fresh private window. You should be redirected to Thoryn, be able to sign in, and land back on the app. Use Cloudflare's login-method Test button for an isolated check of the OIDC round-trip before you gate real traffic.
Pomerium (self-hosted in your VPC)
Pomerium is the self-hosted reference integration: an open-source, OIDC-aware identity-aware
proxy you deploy inside your own VPC (ADR 2026-06-01-endpoint-os-integration-strategy.md names
it the reference for the self-hosted, no-SaaS-dependency path). It authenticates every request
against your IdP before proxying to the upstream app.
1. Register the client in Thoryn
Pomerium's OIDC callback path is fixed at /oauth2/callback under your Pomerium
authenticate service URL:
https://authenticate.<your-domain>/oauth2/callback
Register a confidential application with that redirect URI, openid profile email scopes, and
authorization_code. Keep the clientId / clientSecret.
2. Configure Pomerium's OIDC IdP
Pomerium takes a generic oidc provider and derives all endpoints from the issuer's discovery
document. In config.yaml (or the equivalent environment variables):
# Pomerium — authenticate service IdP configuration
authenticate_service_url: https://authenticate.acme.example
idp_provider: oidc
idp_provider_url: https://{slug}.hub.<platformDomain> # the Thoryn tenant issuer
idp_client_id: app-3f9c1a20b7e4 # the Thoryn clientId
idp_client_secret: s3cr3t-shown-once… # the Thoryn clientSecret
idp_scopes:
- openid
- profile
- emailidp_provider_url is the issuer (no /.well-known/... suffix — Pomerium appends it and
fetches discovery itself). Prefer environment variables (IDP_CLIENT_SECRET) or a mounted
secret for the client secret rather than committing it to config.yaml.
3. Add a route with an authentication policy
Define a route for the internal app and require an authenticated, authorized user:
routes:
- from: https://intranet.acme.example
to: http://intranet.internal.acme.example:8080 # the upstream app, VPC-internal
policy:
- allow:
and:
- authenticated_user: true
- domain:
is: acme.comauthenticated_user: true forces the OIDC sign-in against Thoryn; the domain / email /
groups criteria are Pomerium's own authorization layer on top of the authenticated identity.
For group-based rules, request a groups claim in the token
(Claims in the token) and match on it in the policy.
4. Test end-to-end
Reload Pomerium with the new config and open https://intranet.acme.example in a fresh
session. Pomerium redirects to Thoryn, you sign in, and Pomerium proxies you to the upstream.
Pomerium's authenticate-service logs show the token exchange and the resolved identity; a
401 / 403 there points at the OIDC config or the route policy respectively.
Endpoint reference
All endpoints are Thoryn's, under the tenant issuer
https://{slug}.hub.<platformDomain> (default tenant: https://hub.<platformDomain>). Read
the concrete values from the discovery document; do not hard-code them.
| Purpose | Path | Used by the ZTNA proxy for |
|---|---|---|
| Discovery | /.well-known/openid-configuration | Auto-configuring all endpoints below + the issuer and JWKS |
| Authorization | /oauth2/authorize | Redirecting the browser to sign in (step 1) |
| Token | /oauth2/token | Exchanging the code for tokens (step 3) |
| JWKS | /oauth2/jwks | Validating the ID-token signature |
| UserInfo | /userinfo | Reading subject claims (when the proxy calls UserInfo rather than reading the ID token) |
Client registration (customer plane, behind the API gateway):
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /api/v1/applications | tenant:applications.write | Register the ZTNA proxy as a confidential client; returns clientId (+ clientSecret once). |
GET | /api/v1/applications/{clientId} | tenant:applications.read | Inspect the registered proxy client. |
PATCH | /api/v1/applications/{clientId} | tenant:applications.write | Update its redirectUris / scopes. |
POST | /api/v1/applications/{clientId}/secret/rotate | tenant:applications.write | Rotate the proxy's secret (24h overlap). |
See Integrate an application for the full management surface.
Troubleshooting
These are the OIDC misconfigurations that account for nearly every failed ZTNA integration.
| Symptom | Cause / fix |
|---|---|
invalid_redirect_uri / redirect_uri_mismatch at /oauth2/authorize | The callback URL registered in Thoryn does not exactly match the one the ZTNA product sends. Redirect URIs are matched by exact string — trailing slash, scheme, and case all matter. Copy the callback the ZTNA product shows and register it verbatim. |
| Proxy reports "issuer mismatch" / "id_token issuer invalid" | The iss in the ID token is the tenant issuer (https://{slug}.hub.<platformDomain>), not a bare host or the default issuer. Configure the ZTNA product with the tenant issuer / tenant discovery URL — mixing the default-tenant issuer with a named-tenant client fails validation. |
invalid_scope at /oauth2/authorize | A requested scope is not granted to the proxy client, or openid is missing. Ensure the client is registered with openid profile email and the ZTNA product requests only those. |
| Sign-in succeeds but the policy denies access | The proxy authenticated the user but the groups / email claim it matches on is absent or mismatched. Confirm the identity-claim mapping (usually email), and if you match on groups, request a groups claim in the token — it is not emitted by default. |
invalid_client at /oauth2/token | Wrong or expired clientSecret, or the client type does not match how the proxy authenticates. During a secret rotation the previous secret is valid only until its overlap expires — update the proxy with the new secret. |
| Proxy cannot fetch discovery / JWKS | Network egress from the ZTNA proxy to the Thoryn issuer host is blocked, or it was pointed at an in-cluster hostname it cannot resolve. The proxy must reach the public tenant issuer over HTTPS. |
invalid_grant on token exchange | The code was reused or expired, or PKCE code_verifier did not match. Enable PKCE consistently on the proxy side; do not replay authorization codes. |
Security notes
- Thoryn provides identity only; the ZTNA product provides the network path and policy. Keep that boundary clean — Thoryn does not run a connector agent, terminate the app's traffic, or enforce network access. The internal app must be reachable only through the ZTNA proxy, never exposed directly to the internet.
- The proxy is a confidential client — protect its secret. It is shown once at registration and stored only as a bcrypt hash in the hub; a lost secret is rotated, not recovered (24-hour overlap, so no request drops during the swap). Inject it into the proxy as an environment variable or a mounted secret, never in a committed config file.
- Pin the tenant issuer. Validating the ID token against the exact tenant issuer and its
jwks_uriis what stops a token minted for another tenant (or another issuer) from being accepted. Configure discovery, not hand-typed endpoints, so issuer and keys always agree. - Grant the minimum scopes.
openid profile emailis enough for authentication and policy-matching; addoffline_accessonly if the proxy genuinely maintains a refreshable session. Fewer scopes, smaller blast radius. - EU data residency. Pomerium is fully self-hostable inside your own EU VPC with no SaaS
dependency; Cloudflare Access offers EU data-residency configuration. Choose per your
sovereignty requirements (ADR
2026-06-01-endpoint-os-integration-strategy.md). - Prefer a stable subject for long-lived mappings.
emailis convenient for policy but can be reassigned; usesubwhere the proxy stores a durable per-user identity.
See also
- Integrate an application — the full OAuth-client registration and Authorization Code + PKCE reference the proxy follows.
- Add a federation member — let the users behind the proxy sign in through their own corporate IdP.
- Claims in the token — enrich the ID token with
groups/ role claims for the proxy's access policies.