Issuing Scoped API Keys for OGC Clients

This guide sets up scoped, revocable API keys for the machine consumers of a portal — harvesters, embedded maps, partner integrations — so that rate limits, quotas and audit trails have a stable identity to attach to. It sits under API Gateway, Rate Limiting & Quota Enforcement, within the Core Portal Architecture & Security Boundaries framework.

Prerequisites

  • A gateway able to run an authorisation subrequest per request — Nginx auth_request, Envoy ext_authz, or equivalent.
  • A datastore for key records. A single table in the portal database is sufficient; nothing here needs a dedicated service.
  • A cryptographic random source (secrets in Python, openssl rand) and a password hashing function such as Argon2id.
  • The published layer list, so scopes can be validated against layers that actually exist.

What a key must carry

A key is not only a credential. It is the join point between a request and everything the portal wants to say about the caller: which organisation, what they may reach, what allowance applies, and whether the key is still valid. Deciding those fields before issuing the first key avoids the migration that follows from discovering later that keys cannot be attributed to anyone.

What a key record holds, and what each field makes possible Six fields of a key record, each paired with the operational capability it enables. FIELD MAKES POSSIBLE identifier prefix fast lookup, and a value safe to write in a log hashed secret verification without ever storing the secret owner and contact someone to tell before revoking it scope least privilege: these layers, these operations tier the rate limit that applies to this integration expiry, last used rotation, and finding keys nobody uses any more

Step-by-step implementation

1. Choose a key format that is safe to handle

Split the key into a public prefix and a secret. The prefix is what you index, log, and put in a support ticket; the secret is shown once and never stored. A recognisable leading token also lets secret scanners spot the key if it is ever committed to a repository.

# keys.py — minting and verifying portal API keys
import secrets
from argon2 import PasswordHasher

_hasher = PasswordHasher()          # Argon2id with library defaults
PREFIX_BYTES, SECRET_BYTES = 6, 24

def mint() -> tuple[str, str, str]:
    """Return (display_key, prefix, hash). Show display_key once, store the rest."""
    prefix = secrets.token_hex(PREFIX_BYTES)          # public, indexable
    secret = secrets.token_urlsafe(SECRET_BYTES)      # private, shown once
    display = f"gpk_{prefix}_{secret}"                # scannable leading token
    return display, prefix, _hasher.hash(secret)

def split(display: str) -> tuple[str, str] | None:
    """Parse a presented key into (prefix, secret); None if malformed."""
    parts = display.split("_", 2)
    if len(parts) != 3 or parts[0] != "gpk":
        return None
    return parts[1], parts[2]

def verify(secret: str, stored_hash: str) -> bool:
    try:
        return _hasher.verify(stored_hash, secret)
    except Exception:
        return False                                   # never leak the reason

2. Store the record with its scope

Keep the scope explicit and validated against the published layer list at issue time, so a key can never be created for a layer that does not exist — a scope naming a typo’d layer looks restrictive and grants nothing, which is a support call waiting to happen.

CREATE TABLE api_key (
    prefix         text PRIMARY KEY,
    secret_hash    text        NOT NULL,
    owner_org      text        NOT NULL,
    owner_contact  text        NOT NULL,
    tier           text        NOT NULL DEFAULT 'standard',
    operations     text[]      NOT NULL,   -- e.g. {GetMap,GetCapabilities}
    layers         text[]      NOT NULL,   -- explicit list; '*' is a deliberate choice
    created_at     timestamptz NOT NULL DEFAULT now(),
    expires_at     timestamptz NOT NULL,
    revoked_at     timestamptz,
    last_used_at   timestamptz
);

-- Keys that are usable right now; the authorisation path reads this view.
CREATE VIEW api_key_active AS
SELECT * FROM api_key
WHERE revoked_at IS NULL AND expires_at > now();

3. Authorise the request at the gateway

The subrequest resolves the key, checks the scope against the requested operation and layer, and returns the tenant and tier as headers for the rate limiter to key on. Everything it returns must come from the key record — never from the request — or the scope is decorative.

# authz.py — the endpoint the gateway calls per request
from flask import Flask, request, make_response
import keys, db

app = Flask(__name__)

@app.route("/_authz")
def authz():
    presented = request.headers.get("X-Api-Key", "")
    parsed = keys.split(presented)
    if not parsed:
        return make_response("", 401)

    prefix, secret = parsed
    record = db.fetch_active_key(prefix)          # reads api_key_active
    if not record or not keys.verify(secret, record["secret_hash"]):
        return make_response("", 401)

    op    = (request.headers.get("X-Ogc-Operation") or "").strip()
    layer = (request.headers.get("X-Ogc-Layer") or "").strip()

    if op not in record["operations"]:
        return make_response("", 403)
    if "*" not in record["layers"] and layer not in record["layers"]:
        return make_response("", 403)

    db.touch_last_used(prefix)                    # cheap async update in practice
    resp = make_response("", 204)
    resp.headers["X-Tenant-Id"] = record["owner_org"]
    resp.headers["X-Rate-Tier"] = record["tier"]
    return resp

4. Wire it into the gateway

location = /_authz {
    internal;
    proxy_pass              http://authz_service;
    proxy_pass_request_body off;
    proxy_set_header        Content-Length "";
    proxy_set_header        X-Api-Key        $http_x_api_key;
    proxy_set_header        X-Ogc-Operation  $arg_request;
    proxy_set_header        X-Ogc-Layer      $arg_layers;
}

location /geoserver/ {
    auth_request      /_authz;
    auth_request_set  $tenant $upstream_http_x_tenant_id;
    auth_request_set  $tier   $upstream_http_x_rate_tier;

    # The upstream must never see the caller's key.
    proxy_set_header  X-Api-Key "";
    proxy_set_header  X-Tenant-Id $tenant;
    proxy_pass        http://geoserver_backend;
}

Lifecycle is the part that gets skipped

Issuing keys is easy and takes an afternoon. What determines whether the scheme is still useful in two years is what happens to keys afterwards — and the default outcome, without deliberate effort, is a table of long-lived keys belonging to people who have left, used by systems nobody can name.

Key lifecycle, with the two exits that must exist Issue, active, and two exits — rotation and expiry — plus revocation as an always-available path, and detection of unused keys from the last-used timestamp. issued expiry set now active last-used stamped rotated overlap, then retire the old expired stops working on a known date revoked available at any moment A key with no expiry is a key that will outlive its owner's employment. Set one at issue time, even a long one. Retire keys unused for a quarter: the last-used stamp is the only evidence that an integration still exists.

Scope narrowly, and make widening deliberate

The scope on a key is only useful if it starts narrow. In practice the pressure runs the other way: an integrator asks for access, nobody is sure which layers they need, and a key is issued with every layer “for now”. A default that is easy to widen and hard to narrow ends with every key holding everything.

Three scoping defaults and where each ends up All layers, named layers, and nothing-by-default, compared by the effort each costs and the scope creep each produces. all layers, “for now” no conversation needed, working in a minute narrowing later breaks an integration nobody can test avoid the layers they name one email, and an accurate scope in most cases review once the integration is working, then leave it prefer nothing by default narrowest, at the cost of a round trip on day one for restricted data

Record the scope decision next to the key, in a sentence: which layers, why, and who asked. A year later that sentence is the difference between narrowing a key confidently and leaving it alone because nobody knows what it is for.

Verification

# 1. A valid key within scope is accepted
curl -s -o /dev/null -w '%{http_code}\n' -H "X-Api-Key: $KEY" \
  "https://portal.example.gov/geoserver/wms?service=WMS&request=GetMap&layers=base&bbox=0,0,1,1&width=256&height=256&format=image/png"
#   expect: 200

# 2. The same key is refused for a layer outside its scope
curl -s -o /dev/null -w '%{http_code}\n' -H "X-Api-Key: $KEY" \
  "https://portal.example.gov/geoserver/wms?service=WMS&request=GetMap&layers=restricted&bbox=0,0,1,1&width=256&height=256&format=image/png"
#   expect: 403

# 3. A tampered secret with a valid prefix is refused
curl -s -o /dev/null -w '%{http_code}\n' -H "X-Api-Key: ${KEY%?}X" \
  "https://portal.example.gov/geoserver/wms?service=WMS&request=GetCapabilities"
#   expect: 401

# 4. The upstream never receives the key
kubectl logs deploy/geoserver -n geoportal --since=2m | grep -c 'X-Api-Key'
#   expect: 0

# 5. Revocation takes effect on the next request, not on a cache expiry
psql -c "UPDATE api_key SET revoked_at = now() WHERE prefix = '$PREFIX';"
curl -s -o /dev/null -w '%{http_code}\n' -H "X-Api-Key: $KEY" \
  "https://portal.example.gov/geoserver/wms?service=WMS&request=GetCapabilities"
#   expect: 401

Check 5 is the one to run after any caching is added to the authorisation path. Caching key lookups is a reasonable optimisation, and a revocation that takes five minutes to apply is a materially different security property from one that applies immediately — decide which you have, and write it down.

Troubleshooting matrix

Symptom Likely cause Fix
Every request is 401 after deployment The gateway forwards the key header under a different name Confirm the header name end to end; log the header the authoriser receives
Keys work for tiles but not for feature requests Scope check reads a layer parameter that WFS spells differently Normalise layers and typeNames into one value before the scope check
Authorisation adds tens of milliseconds to every request Argon2 verification on every call Cache verified prefixes briefly, and accept the revocation delay knowingly
A revoked key still works The active-key view is cached, or revocation wrote to a replica Read the view directly on the authorisation path; check replica lag
Nobody can say what a key is for Owner and contact left blank at issue time Make both fields required; retire keys that cannot be attributed
Keys leak into browser network logs A key used by a public web map is visible to anyone Public maps need an origin-restricted key or no key at all — not a shared secret
Old keys accumulate indefinitely No expiry set, and no review of last_used_at Set expiry at issue; retire anything unused for a quarter

FAQ

Are API keys enough on their own, without user authentication?

For machine consumers reading published data, usually yes: the key identifies the integration, carries its scope, and can be revoked. For anything touching restricted layers or writing data, no — a key is a shared secret held by a system, and the audit question there is which person authorised the change, which only a user identity answers.

Where should a key be presented — header, query string, or both?

A header, and only a header. Query strings appear in access logs, proxy logs, browser history and referrer headers, so a key in a URL is a key that has been written to several places nobody intended. Accepting it in the query string “for convenience” guarantees that it becomes the common case.

How long should a key live?

Long enough not to be an operational nuisance, short enough to bound an undetected compromise — a year is a common compromise for partner integrations, with a rotation window of a few weeks during which both keys work. The important part is that an expiry exists at all, so a forgotten key stops working on a known date rather than living indefinitely.

Can a public web map use a key safely?

Not as a secret, because anything in a browser is visible to whoever opens the developer tools. What a key can still do there is identify the application for rate limiting and attribution, provided it is restricted to the origins the map is served from and scoped to public layers only. Treat it as an identifier, not a credential.

What should happen to traffic from a key that has expired?

Reject it with a distinct status and message, and alert the owner rather than only the caller. An expired key almost always means a rotation that was never completed, and the integration’s owner is usually unaware — the failure surfaces first as a partner’s dashboard going blank, which is a worse way to learn about it.

Up one level: API Gateway, Rate Limiting & Quota Enforcement.