Today (Mar 2026) GeoCatalogs do not support:
- Anonymous access
- Collection-level RBAC
We can implement them by deploying some kind of HTTP proxy between users and the geocatalog. One such proxy is API Management.
flowchart LR
A["Anonymous Users"] -- "x" --x B["GeoCatalog"]
flowchart LR
A["Anonymous Users"] --> B["APIM"]
B -- "User-Assigned Managed Identity" --> C["GeoCatalog"]
- Create/have a GeoCagalog
- Create/have a user-assigned managed identity
- Give the managed identity the
GeoCatalog Readerrole over the geocatalog.
- Create an API Management instance
- Assign the resource the user assigned managed identity you have already created
- Define the following API policies:
The API has a single inbound policy:
authentication-managed-identity. This policy acquires a
token from the user-assigned managed identity and attaches it
to every request forwarded to the GeoCatalog backend.
<policies>
<inbound>
<base />
<authentication-managed-identity
resource="https://geocatalog.spatio.azure.com"
client-id="<managed-identity-client-id>" />
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
<find-and-replace
from="https://<name>.<id>.<region>.geocatalog.spatio.azure.com"
to="https://<apim-name>.azure-api.net" />
</outbound>
<on-error>
<base />
</on-error>
</policies>resource— the audience of the token. All GeoCatalogs share the same audience (https://geocatalog.spatio.azure.com).client-id— the client ID of the user-assigned managed identity that APIM uses to authenticate.find-and-replace— rewrites the GeoCatalog backend URL in response bodies to the APIM gateway URL. Without this, STAC links (self, root, parent, etc.) would expose the backend URL to callers.
The global (service-level) policy is the default: it simply forwards the request to the backend with no additional processing.
When creating the API in APIM, configure it as follows:
| Setting | Value |
|---|---|
| Display name | A descriptive name (e.g. GeoCatalog API) |
| Web service URL | Your GeoCatalog endpoint, e.g. https://<name>.<id>.<region>.geocatalog.spatio.azure.com |
| URL scheme | HTTPS |
| API URL suffix | Leave empty (root path) |
| Subscription required | No (callers do not need to present an APIM subscription key) |
Then define the following operations to proxy traffic to the GeoCatalog:
| Operation name | Method | URL template |
|---|---|---|
| GET | GET | /* |
| Get collection items | GET | /stac/collections/{collection_id}/items |
| Get single collection | GET | /stac/collections/{collection_id} |
| Get collection sub-resources | GET | /stac/collections/{collection_id}/* |
| POST | POST | /* |
The wildcard (/*) operations forward all matching requests
to the GeoCatalog backend. The explicit collection operations
allow applying collection-specific RBAC policies to prevent
unauthorized access to individual collection metadata, items,
and sub-resources.
By default a GeoCatalog exposes all its collections to any authenticated caller. To restrict which collections are visible through APIM, apply operation-level policies that block broad STAC discovery and enforce an allow-list.
Block the routes that would reveal every collection in the
catalog. Add the following operations and attach a policy
that returns 404 immediately:
| Operation name | Method | URL template |
|---|---|---|
| Block root | GET | / |
| Block collections | GET | /stac/collections |
Apply this operation-level policy to both:
<policies>
<inbound>
<base />
<return-response>
<set-status code="404" reason="Not Found" />
</return-response>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>The STAC /stac/search endpoint accepts a collections
parameter — as a query string on GET or in the JSON body on
POST. Without guardrails a caller could search across every
collection in the catalog. The policies below validate that
only collections from an allowed set are requested.
Define a named value in APIM called allowed-collections
containing a comma-separated list of permitted collection IDs
(e.g. sentinel-2-l2a,landsat-8-c2-l2).
Add two operations:
| Operation name | Method | URL template |
|---|---|---|
| GET search | GET | /stac/search |
| POST search | POST | /stac/search |
Validates the collections query parameter. Each
comma-separated value must be in the allowed set.
Note
APIM policy expressions run in a restricted C#
environment. Use condition='@{...}' (single-quoted
attribute) so double-quotes work inside the expression.
Avoid generic type parameters (e.g.
GetValueOrDefault<string>) and LINQ lambdas — use
explicit casts and foreach loops instead.
<policies>
<inbound>
<base />
<set-variable name="allowedCsv"
value="{{allowed-collections}}" />
<choose>
<when condition='@{
var allowed = ((string)context
.Variables["allowedCsv"])
.Trim().ToLower();
var raw = context.Request.Url.Query
.GetValueOrDefault("collections", "");
if (string.IsNullOrWhiteSpace(raw)) {
return true;
}
foreach (var c in raw.ToLower().Split(
new [] { "," },
StringSplitOptions.RemoveEmptyEntries))
{
if (!c.Trim().Equals(allowed)) {
return true;
}
}
return false;
}'>
<return-response>
<set-status code="403"
reason="Forbidden" />
<set-body>
Collection not allowed.
</set-body>
</return-response>
</when>
</choose>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>Parses the JSON body and validates the collections array.
<policies>
<inbound>
<base />
<set-variable name="allowedCsv"
value="{{allowed-collections}}" />
<set-variable name="requestBody"
value="@(context.Request.Body
.As<string>(
preserveContent: true))" />
<choose>
<when condition='@{
var allowed = ((string)context
.Variables["allowedCsv"])
.Trim().ToLower();
var body = (string)context
.Variables["requestBody"];
var json = Newtonsoft.Json.Linq
.JObject.Parse(body);
var arr = json["collections"]
as Newtonsoft.Json.Linq.JArray;
if (arr == null || arr.Count == 0) {
return true;
}
foreach (var token in arr) {
if (!token.ToString().Trim()
.ToLower().Equals(allowed))
{
return true;
}
}
return false;
}'>
<return-response>
<set-status code="403"
reason="Forbidden" />
<set-body>
Collection not allowed.
</set-body>
</return-response>
</when>
</choose>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>How do I update the allowed collections list?
Edit the allowed-collections named value in the APIM
instance. No policy changes are needed.
What happens if a caller omits the collections parameter?
The request is rejected with 403 Forbidden. Callers must
always specify which collections they want to search.
The GeoCatalog SAS API lets callers generate storage tokens and sign asset HREFs. Without restrictions a caller could obtain tokens for any collection. The policies below ensure that only allowed collections can be accessed.
Add the following operations:
| Operation name | Method | URL template |
|---|---|---|
| GET SAS token | GET | /sas/token/{collection_id} |
| Block SAS sign | GET | /sas/sign |
The /sas/sign endpoint is blocked entirely. Callers should
use /sas/token/{collection_id} to obtain collection-level
SAS tokens instead.
Apply the 404 blocking policy (same as the root/collections
block above) to the Block SAS sign operation.
Validates the collection_id path parameter against the
allowed set.
<policies>
<inbound>
<base />
<set-variable name="allowedCsv"
value="{{allowed-collections}}" />
<choose>
<when condition='@{
var allowed = ((string)context
.Variables["allowedCsv"])
.Trim().ToLower();
var collectionId = (string)context
.Request.MatchedParameters[
"collection_id"];
return !collectionId.Trim()
.ToLower().Equals(allowed);
}'>
<return-response>
<set-status code="403"
reason="Forbidden" />
<set-body>
Collection not allowed.
</set-body>
</return-response>
</when>
</choose>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>The /data/mosaic/ endpoints provide tile rendering,
bounding-box crops, and search registration. Two policy
groups are needed:
- Register search — validate the
collectionsarray in the JSON body. - All other collection routes — validate the
collectionIdpath parameter.
Add the following operations:
| Operation name | Method | URL template |
|---|---|---|
| POST register search | POST | /data/mosaic/register |
| GET data collection | GET | /data/mosaic/collections/{collectionId}/* |
Validates the collections array in the JSON body against
the allowed set. Requests without a collections parameter
are rejected.
<policies>
<inbound>
<base />
<set-variable name="allowedCsv"
value="{{allowed-collections}}" />
<set-variable name="requestBody"
value="@(context.Request.Body
.As<string>(
preserveContent: true))" />
<choose>
<when condition='@{
var allowed = ((string)context
.Variables["allowedCsv"])
.Trim().ToLower();
var body = (string)context
.Variables["requestBody"];
var json = Newtonsoft.Json.Linq
.JObject.Parse(body);
var arr = json["collections"]
as Newtonsoft.Json.Linq.JArray;
if (arr == null || arr.Count == 0) {
return true;
}
foreach (var token in arr) {
if (!token.ToString().Trim()
.ToLower().Equals(allowed))
{
return true;
}
}
return false;
}'>
<return-response>
<set-status code="403"
reason="Forbidden" />
<set-body>
Collection not allowed.
</set-body>
</return-response>
</when>
</choose>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>Validates the collectionId path parameter against the
allowed set. Apply this policy to both the GET and POST
operations.
<policies>
<inbound>
<base />
<set-variable name="allowedCsv"
value="{{allowed-collections}}" />
<choose>
<when condition='@{
var allowed = ((string)context
.Variables["allowedCsv"])
.Trim().ToLower();
var collectionId = (string)context
.Request.MatchedParameters[
"collectionId"];
return !collectionId.Trim()
.ToLower().Equals(allowed);
}'>
<return-response>
<set-status code="403"
reason="Forbidden" />
<set-body>
Collection not allowed.
</set-body>
</return-response>
</when>
</choose>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>Without explicit operations, requests like
GET /stac/collections/sentinel-2-l2a or
GET /stac/collections/sentinel-2-l2a/items fall through
to the GET /* wildcard and reach the backend without any
collection-level check. Add the following operations to
enforce the allow-list on individual collection endpoints:
| Operation name | Method | URL template |
|---|---|---|
| Get single collection | GET | /stac/collections/{collection_id} |
| Get collection sub-resources | GET | /stac/collections/{collection_id}/* |
| Get collection items | GET | /stac/collections/{collection_id}/items |
Apply the same path-parameter ACL policy used for SAS tokens
(validates collection_id against {{allowed-collections}})
to all three operations.
Replace <apim-gateway> with your APIM gateway URL
(e.g. https://XXX.azure-api.net).
curl "https://<apim-gateway>/stac/collections"curl -X POST "https://<apim-gateway>/stac/search" \
-H "Content-Type: application/json" \
-d '{
"collections": ["sentinel-2-l2a"],
"bbox": [-122.5, 37.5, -122.0, 38.0],
"datetime": "2024-01-01T00:00:00Z/2024-02-01T00:00:00Z",
"limit": 5
}'curl "https://<apim-gateway>/sas/token/sentinel-2-l2a"curl -o preview.png \
"https://<apim-gateway>/data/collections/sentinel-2-l2a/items/<item-id>/preview.png?api-version=2025-04-30-preview"The collection-level RBAC above uses a single
allowed-collections named value — every caller sees the
same set. To give different users different collection
access (e.g. basic vs. premium tiers), use APIM Products
with per-product policies.
flowchart LR
B["Basic subscriber"] -- "subscription key" --> PB["Basic product"]
P["Premium subscriber"] -- "subscription key" --> PP["Premium product"]
PB --> API["GeoCatalog API"]
PP --> API
API --> GC["GeoCatalog"]
- A Product groups one or more APIs and issues its own subscription keys.
- Each product has its own policy scope, so the
allowed-collectionsvalue can differ per product. - When APIM receives a subscription key it automatically resolves the associated product and applies that product's policies.
| Product | Subscription required | Collections |
|---|---|---|
| Basic | Yes | sentinel-2-l2a |
| Premium | Yes | sentinel-2-l2a,landsat-8-c2-l2,sentinel-1-grd |
Add the GeoCatalog API to both products.
Create one named value per product:
| Named value | Value |
|---|---|
basic-allowed-collections |
sentinel-2-l2a |
premium-allowed-collections |
sentinel-2-l2a,landsat-8-c2-l2,sentinel-1-grd |
On each product, add an inbound policy that sets the
allowedCsv variable for downstream operation policies
to consume.
Basic product policy:
<policies>
<inbound>
<base />
<set-variable name="allowedCsv"
value="{{basic-allowed-collections}}" />
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>Premium product policy:
<policies>
<inbound>
<base />
<set-variable name="allowedCsv"
value="{{premium-allowed-collections}}" />
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>The operation-level policies (search, SAS, data routes,
collection endpoints) no longer need to set allowedCsv
themselves — it is already set by the product policy.
Remove the <set-variable name="allowedCsv" .../> line
from each operation policy; the rest of the validation
logic stays the same.
Create a subscription under each product for each user or application. The subscription key determines which product (and therefore which allowed-collections set) applies.
Callers include their subscription key in requests:
# Basic subscriber — only sentinel-2-l2a is allowed
curl -H "Ocp-Apim-Subscription-Key: <basic-key>" \
"https://<apim-gateway>/stac/search?collections=sentinel-2-l2a"
# Premium subscriber — all three collections are allowed
curl -H "Ocp-Apim-Subscription-Key: <premium-key>" \
-X POST "https://<apim-gateway>/stac/search" \
-H "Content-Type: application/json" \
-d '{
"collections": [
"sentinel-2-l2a",
"landsat-8-c2-l2",
"sentinel-1-grd"
]
}'A basic subscriber requesting a premium-only collection
receives 403 Forbidden.
- Create a new named value
(e.g.
enterprise-allowed-collections). - Create a new product with a product-level policy that
sets
allowedCsvto{{enterprise-allowed-collections}}. - Add the GeoCatalog API to the product.
- Issue subscriptions under the new product.
No operation-level policy changes are needed.