Embedding APM Protected Resources into Third-Party Sites
How to handle CORS in combination with APM
This is the story of a fight with CORS (Cross-Origin Resource Sharing) and the process of understanding how it actually works.
APM is a powerful tool that provides solutions for both simple and complex use cases. However, I have historically struggled with one specific scenario: embedding an APM-protected application into a third-party site. Unfortunately APM doesn't provide any mechanism to make this easy.
Everything works when accessing the APM application directly. But when the same application was embedded in another site, it failed silently—nothing rendered, and the browser console filled with errors. Since I never had the opportunity to fully investigate the issue, alternative solutions were usually chosen.
Recently, I worked with a customer who needed protection for a web application, and APM seemed like the right fit. The application was divided into public and private components, where only the private resources required authentication. This appeared straightforward, and I initially assumed that some iRule logic would be sufficient. But unbeknownst to me the site was not always the entry point to the application which meant that I now had the same old problem with serving content to a 3. party site.
Now I was forced to look into CORS for real and how to handle this beast.
I used a fair amount of time to get a better understanding and to my surprise it wasn't really all that difficult to fix, it looked like I just needed to inject a couple of headers in the response and then it would all be dandy.
This is where the complexity began - specifically around iRules on a virtual server with APM enabled; I just couldn't get it to insert the headers the way I wanted. While there are multiple ways to solve this, I chose a layered virtual server design to keep the logic clean and predictable.
This weird construct has been my goto solution for some time now and proven quite reliable. With a layered VS you have two virtual servers which are glued together via an iRule command "virtual". This gives you the option to run modules, iRules, policies etc. independently of each other. One of the VS' is the entry point and based on the logic you then forward traffic to the second one.
In this setup the frontend virtual server - the one exposed to the users - has a single iRule responsible for:
- Handling CORS preflight requests by returning the appropriate headers
- Injecting CORS headers into responses when the origin is allowed
- Forwarding all non-preflight requests to the APM virtual server
This separation ensures full control over HTTP processing without being constrained by APM behavior.
On the inner APM virtual server, an access profile is attached which handles authentication. I usually set a non-reachable IP address on the inner VS to ensure that it can only be reached via the frontend VS.
Initially, I planned to use a per-request policy on the inner APM VS with a URL branch agent to determine whether a request should be authenticated or not. While this looked correct on paper, it failed in practice. The third-party site was requesting embedded resources (such as images), and its logic could not handle APM redirects (e.g., '/my.policy').
One possible workaround was to use "clientless mode" by inserting the special header:
HTTP::header insert "clientless-mode" 1
However, this introduces additional logic without providing real benefits and I wasn't sure if the application would handle the session cookie correctly or just create a million new APM sessions.
Instead, I implemented an iRule that performs a datagroup lookup. If a match is found, APM is bypassed entirely for that request. This approach is simpler to maintain and reduces load by not utilizing the APM module.
Below is a diagram illustrating the request flow and decision logic:
-
A user browses the 3. party site which has links to our site. The Origin site.
-
The browser retrieves the resources on our site.
-
Should the browser decided to make a preflight request, the iRule will verify the Origin header from a datagroup and if allowed return the CORS headers.
-
Forward the traffic to the inner APM VS.
-
The iRule on the inner APM VS looks up in a datagroup and find no match and the authentication process is executed.
-
The iRule on the inner APM VS looks up in a datagroup and find a match and disables APM.
-
On the response from the backend we check if the origin was on the approved list.
-
and if so we inject the CORS headers which will allow for the browser to show the content.
- A user browses the site directly which will bypass any CORS logic.
Here is the iRule for the frontend VS:
# =============================================================================
# iRule: Outer virtual for redirect + CORS before APM
# -----------------------------------------------------------------------------
# Purpose:
# - Redirect "/" on portal.example.com to /portal/home/
# - Handle CORS preflight locally before APM
# - Forward all other traffic to the inner APM virtual
# - Inject CORS headers into normal responses
#
# Dependencies:
# - Attached to outer/public virtual server
# - Inner virtual server exists and is called via "virtual"
# - Internal string datagroup for allowed CORS origin hostnames
#
# Datagroups:
# - portal_example_com_allowed_cors_origins_dg
#
# Notes:
# - Datagroup must contain lowercase hostnames only
# - Empty datagroup = allow all origins
#
# =============================================================================
when RULE_INIT {
set static::portal_example_com_outer_cors_debug_enabled 1
}
when HTTP_REQUEST {
# Initialize request-scoped state explicitly.
set cors_origin ""
set cors_origin_host ""
set cors_is_allowed 0
if { $static::portal_example_com_outer_cors_debug_enabled } {
log local0. "debug: HTTP_REQUEST start - host=[HTTP::host] uri=[HTTP::uri] method=[HTTP::method]"
}
# -------------------------------------------------------------------------
# Root redirect
# -------------------------------------------------------------------------
if { ([string tolower [getfield [HTTP::host] ":" 1]] eq "portal.example.com") && ([HTTP::uri] eq "/") } {
if { $static::portal_example_com_outer_cors_debug_enabled } {
log local0. "debug: root redirect matched"
}
HTTP::redirect "https://portal.example.com/portal/home/"
return
}
# -------------------------------------------------------------------------
# Origin evaluation (empty DG = allow all)
# -------------------------------------------------------------------------
if { [HTTP::header exists "Origin"] } {
set cors_origin [HTTP::header "Origin"]
set cors_origin_host [string tolower [URI::host $cors_origin]]
if { $static::portal_example_com_outer_cors_debug_enabled } {
log local0. "debug: origin='$cors_origin' parsed_host='$cors_origin_host'"
}
if {
([class size portal_example_com_allowed_cors_origins_dg] == 0) ||
($cors_origin_host ne "" && [class match -- $cors_origin_host equals portal_example_com_allowed_cors_origins_dg])
} {
set cors_is_allowed 1
if { $static::portal_example_com_outer_cors_debug_enabled } {
log local0. "debug: origin allowed"
}
} else {
if { $static::portal_example_com_outer_cors_debug_enabled } {
log local0. "debug: origin NOT allowed"
}
}
} else {
if { $static::portal_example_com_outer_cors_debug_enabled } {
log local0. "debug: no Origin header"
}
}
# -------------------------------------------------------------------------
# Preflight handling
# -------------------------------------------------------------------------
if { $cors_is_allowed } {
if { ([HTTP::method] eq "OPTIONS") && ([HTTP::header exists "Access-Control-Request-Method"]) } {
if { $static::portal_example_com_outer_cors_debug_enabled } {
log local0. "debug: handling preflight locally"
}
HTTP::respond 200 noserver \
"Access-Control-Allow-Origin" $cors_origin \
"Access-Control-Allow-Methods" "GET, POST, OPTIONS" \
"Access-Control-Allow-Headers" [HTTP::header "Access-Control-Request-Headers"] \
"Access-Control-Max-Age" "86400" \
"Vary" "Origin"
return
}
}
if { $static::portal_example_com_outer_cors_debug_enabled } {
log local0. "debug: forwarding to inner virtual"
}
# -------------------------------------------------------------------------
# Forward to inner APM virtual
# -------------------------------------------------------------------------
virtual portal.example.com_https_vs
}
when HTTP_RESPONSE {
if { $cors_is_allowed && $cors_origin ne "" } {
if { $static::portal_example_com_outer_cors_debug_enabled } {
log local0. "debug: injecting CORS headers"
}
HTTP::header replace "Access-Control-Allow-Origin" $cors_origin
HTTP::header replace "Vary" "Origin"
}
}
Here is the iRule for the inner APM VS:
# =============================================================================
# iRule: Selective APM bypass for portal.example.com using datagroups
# -----------------------------------------------------------------------------
# Purpose:
# Disable APM only for explicitly public endpoint prefixes while preserving
# APM protection for everything else.
#
# Dependencies:
# - BIG-IP LTM
# - BIG-IP APM
# - Internal string datagroup for public path prefixes
#
# Datagroups:
# - gisportal_public_path_prefixes_dg
#
# Notes:
# - Matching is done against the raw request path derived from HTTP::uri.
# - Only the query string is stripped.
# - Datagroup entries are matched using starts_with behavior.
# - APM is disabled only when a request matches an explicit public prefix.
# - All other requests remain protected by APM.
# - Debug logging can be enabled in RULE_INIT by setting:
# static::portal_example_com_apm_bypass_debug 1
#
# =============================================================================
when RULE_INIT {
set static::portal_example_com_apm_bypass_debug 1
}
when HTTP_REQUEST {
set normalized_host [string tolower [getfield [HTTP::host] ":" 1]]
set normalized_uri [string tolower [HTTP::uri]]
# -------------------------------------------------------------------------
# Extract request path from raw URI
# -------------------------------------------------------------------------
set query_delimiter_index [string first "?" $normalized_uri]
if { $query_delimiter_index >= 0 } {
set normalized_path [string range $normalized_uri 0 [expr {$query_delimiter_index - 1}]]
} else {
set normalized_path $normalized_uri
}
if { $static::portal_example_com_apm_bypass_debug } {
log local0. "debug: host='$normalized_host' uri='$normalized_uri' path='$normalized_path'"
}
if { $normalized_host ne "portal.example.com" } {
if { $static::portal_example_com_apm_bypass_debug } {
log local0. "debug: host did not match target host, skipping rule"
}
return
}
# -------------------------------------------------------------------------
# PUBLIC
# -------------------------------------------------------------------------
set matched_public_prefix [class match -name -- $normalized_path starts_with gisportal_public_path_prefixes_dg]
if { $static::portal_example_com_apm_bypass_debug } {
if { $matched_public_prefix ne "" } {
log local0. "debug: matched PUBLIC prefix '$matched_public_prefix' -> disabling APM"
} else {
log local0. "debug: no public prefix matched -> APM remains enabled"
}
}
if { $matched_public_prefix ne "" } {
ACCESS::disable
return
}
}
I can recommend spending time on YouTube to get a better feeling about what CORS is about and why it is being used. You might run into another problem with APM if you try to embed the entire application, with APM authentication logic, into a frame in a 3. party app. I have not addressed it in this example, but you could extend the iRule logic to inject CSP headers to make this possible. I might address this in another article.
I hope this example can help you fast-track pass all the headaches I struggled with.
If you have any feedback just let me know. I don't know everything about CORS and I'm sure there are areas which requires special attention that I haven't addressed. Tell me so we can enrich the solution by sharing knowledge.