Skip to content

    Apigee

    Securing APIs in Apigee: OAuth, JWT, mTLS, Threat Protection

    Secure Apigee APIs in layers: API keys, OAuth 2.0 client credentials, JWT verification, mTLS, CORS and threat protection, with working policy XML and curl.

    By Published 20 min read

    Share

    This lesson is part of Apigee API Management: Beginner to Advanced. Enroll for free, then sign in, to read it in full.

    What you'll learn: You will be able to choose between API keys, OAuth 2.0 and JWT for each kind of caller, write the policies that enforce them (VerifyAPIKey, OAuthV2, VerifyJWT, CORS, JSONThreatProtection), secure both sides of the gateway with TLS and mTLS, and order the checks so the cheapest rejections happen first.

    A gateway is like airport security. One checkpoint is never enough: there is a ticket check, an ID check, a bag scan and a gate agent, and each one catches something the others cannot. Skipping a checkpoint because "the previous one probably handled it" is how problems reach the aircraft.

    An API behind Apigee works the same way. Apigee is the policy enforcement point where you verify who is calling, what they may call, how much they may send, and whether the message itself is hostile. This lesson walks through each layer, shows the policy XML for it, and ends with a request-by-request test you can run with curl. Target platform is Apigee X on Google Cloud; where hybrid or older Edge behaves differently, the lesson says so or stays silent.

    The core idea

    Defence in depth means several cheap, independent checks, each rejecting a different kind of bad request. Order them so the cheapest check that can reject a request runs first, and never rely on a single layer.

    Layered defence for an API served through Apigee on Google CloudAPI clients to Load balancer (HTTPS); Load balancer to Apigee proxy (filtered); Apigee proxy to Orders backend (TLS, mTLS); Apigee proxy to Key value map (reads at runtime)GOOGLE CLOUDAPI clients: apps, partners, browsersAPI clientsapps, partners,browsersLoad balancer: TLS and Cloud Armor rulesLoad balancerTLS and CloudArmor rulesApigee proxy: auth, quota, threat checksApigee proxyauth, quota,threat checksKey value map: encrypted config, secretsKey value mapencrypted config,secretsOrders backend: object-level authorisationOrders backendobject-levelauthorisationHTTPSfilteredTLS, mTLSreads at runtime

    How to read it: the first layer (load balancer with Google Cloud Armor, Google's web application firewall) rejects network-level abuse before Apigee spends any capacity. Apigee then checks identity and message content. The backend still owns the decisions only it can make, such as whether this user may read this particular order. Google's own architecture guidance for Apigee describes this arrangement, with Cloud Armor in front as a WAF and Apigee executing the API security policies behind it.

    Where the gateway helps, and where it cannot

    The OWASP API Security Top 10 (2023 edition) is a useful checklist, but be honest about which items a gateway can address:

    OWASP item What Apigee can do What it cannot do
    API2 Broken Authentication Enforce key, token or JWT verification on every request; set continueOnError to false on those policies Fix weak identity provider settings or stolen credentials
    API4 Unrestricted Resource Consumption Spike arrest, quota, payload and structure limits, response caching Fix an expensive backend query
    API5 Broken Function Level Authorization API products limit which operations an app may call; OAuth scopes can be checked at verification Model your full role system
    API8 Security Misconfiguration Standard policies for CORS, TLS settings, consistent error responses Detect every misconfiguration in the backend
    API1 Broken Object Level Authorization Pass a verified identity to the backend Decide whether user A may read order 1001. Only the backend knows the owner of that order.

    The last row matters most. A gateway authenticates and shapes traffic; object-level authorisation belongs in the service that owns the data.

    Authentication: pick the mechanism by caller

    Apigee offers several ways to identify a caller. They differ in strength, in who issues the credential, and in how fast you can revoke it.

    API keys (VerifyAPIKey)

    When a developer registers an app, Apigee generates a consumer key and secret. The key is what clients send. The VerifyAPIKey policy checks that the key is valid, not revoked, and approved for the API product that contains the called resource.

    An API key identifies an app; it is not strong authentication. It travels with every request, it cannot carry claims, and anyone who copies it can use it. That is fine for low-risk read access by known partners over TLS, and useful as a billing and quota identity. The official docs advise sending the key in a header rather than a query parameter, because query strings end up in browser history and logs.

    After a successful check, the policy sets flow variables under verifyapikey.{policy_name}: client_id, developer.app.name, apiproduct.name, the quota settings on the product, and custom attributes. Later policies (Quota, logging, header injection) read them. The policy also caches verification results; the default cache expiry is 180 seconds and you can lower it with CacheExpiryInSeconds (1 to 180), at some cost to performance. Plan for revocation to take effect within that window, not instantly.

    OAuth 2.0 (OAuthV2)

    OAuthV2 is one policy with many operations. The docs list these: GenerateAccessToken, GenerateAccessTokenImplicitGrant, GenerateAuthorizationCode, RefreshAccessToken, VerifyAccessToken, InvalidateToken (revoke) and ValidateToken (reinstate a revoked token). In Apigee the same policy type issues tokens (on a token endpoint proxy) and verifies them (on your API proxies). Supported grant types are authorization_code, client_credentials, implicit and password.

    Use client credentials when one backend system calls another and no human is involved. Use authorization code when a person delegates access to an app. The OAuth 2.0 Security Best Current Practice (RFC 9700) says clients should not use the implicit grant and that the resource owner password credentials grant must not be used, so treat both as legacy options even though the policy still supports them.

    Tokens give you things keys cannot: a short lifetime, scopes, and per-token revocation. Two caveats from the docs matter in design:

    • Apigee caches OAuth access tokens for at least 180 seconds after they are accessed, so the docs warn that a revoked token may still succeed for up to three minutes. ExpiresIn is in milliseconds (3600000 is one hour).
    • By default GenerateAccessToken produces an opaque token. The docs also describe a JWT-formatted option through a GenerateJWTAccessToken operation, so downstream services can verify tokens themselves.

    JWT policies (GenerateJWT, VerifyJWT, DecodeJWT)

    A JWT is a signed token carrying claims such as issuer (iss), subject (sub), audience (aud) and expiry (exp). Apigee has three policies:

    • GenerateJWT creates a signed (or encrypted) JWT, for example to pass a verified identity to a backend.
    • VerifyJWT checks the signature and, optionally, claims (Issuer, Audience, Subject) and exposes the claims as flow variables (jwt.{policy_name}.decoded.claim.{name}).
    • DecodeJWT reads claims without verifying the signature. Use it only to inspect a token before verification, for example to choose which key to use. Never make an access decision from decoded-only data.

    For tokens issued by an external identity provider, VerifyJWT can fetch public keys from a JWKS (JSON Web Key Set) endpoint and pick the key using the token's kid header. When the JWKS comes from a URL, Apigee caches it for 300 seconds. If the JWT lacks a kid, or the kid is not in the set, the policy raises an error. TimeAllowance adds a grace period for clock skew; the default is zero.

    Basic authentication, SAML, HMAC and IAM

    The BasicAuthentication policy does not protect your API. It Base64-encodes or decodes a username and password, usually to build an Authorization header for a legacy backend. If you want to require Basic credentials from callers, you must decode and then validate them yourself. Apigee also provides SAMLAssertion and HMAC policies for those specific needs, and a VerifyIAM policy, described as an alternative to OAuthV2 and VerifyAPIKey that checks Google Cloud IAM credentials (not available with hybrid).

    Which one should I use?

    The flowchart gives a starting point; real requirements (existing identity provider, compliance) can override it.

    flowchart TD A{"Is a person delegating access to an app?"} -->|yes| B["OAuth authorization code via your identity provider; verify the JWT or token in Apigee"] A -->|no| C{"Another server calling with its own identity?"} C -->|yes| D{"Need short-lived tokens, scopes and per-app revocation?"} D -->|yes| E["OAuth client credentials: OAuthV2 token endpoint, then VerifyAccessToken"] D -->|no| F["API key over TLS, optionally plus IP allow-list"] C -->|no| G{"Does an identity provider already issue JWTs?"} G -->|yes| H["VerifyJWT with the provider's JWKS"] G -->|no| F E --> I{"Does the backend demand a client certificate?"} F --> I H --> I B --> I I -->|yes| J["Add southbound mTLS on the target; this is in addition, not instead"] I -->|no| K["Done: add quota and threat checks"]

    How to read it: mTLS never replaces an authentication choice at the top of the API. It is an extra transport guarantee, usually for the backend leg.

    How the request-validation order works

    Apigee executes policies in order inside the ProxyEndpoint request flow: PreFlow first, then the matching conditional flow, then PostFlow. Put the checks that reject most cheaply first.

    flowchart LR A["Load balancer and Cloud Armor"] --> B["SpikeArrest"] B --> C["AccessControl (IP rules, optional)"] C --> D["Preflight shortcut: CORS on OPTIONS"] D --> E["Authenticate: key, token or JWT"] E --> F["Quota"] F --> G["Threat protection: JSON, XML, regex"] G --> H["Strip credentials, set identity headers"] H --> I["Target server"]

    Some of this order comes straight from the docs: SpikeArrest belongs first in the PreFlow and before Quota, Quota goes after authentication (it needs the identity), and AccessControl is recommended at the start of the flow when you use it. Putting threat protection after authentication is a judgement call: it keeps anonymous callers from making the gateway parse bodies. Reverse it if your threat model says otherwise.

    How to implement it, step by step

    The running example is an Orders API. Hostnames are illustrative (api.example.com), and policy names follow a prefix convention (VK- for VerifyAPIKey, OAuth- for OAuthV2, JTP- for JSON threat protection) so a flow reads clearly.

    Step 1: API key verification

    <VerifyAPIKey name="VK-VerifyAPIKey" continueOnError="false" enabled="true">
      <DisplayName>VK-VerifyAPIKey</DisplayName>
      <APIKey ref="request.header.x-apikey"/>
      <CacheExpiryInSeconds>60</CacheExpiryInSeconds>
    </VerifyAPIKey>

    The key is read from the x-apikey header. A 60-second cache trades some performance for faster revocation. Attach it in the ProxyEndpoint request PreFlow. Then strip the key so it never reaches the backend:

    <AssignMessage name="AM-RemoveApiKey" continueOnError="false" enabled="true">
      <DisplayName>AM-RemoveApiKey</DisplayName>
      <Remove>
        <Headers>
          <Header name="x-apikey"/>
        </Headers>
      </Remove>
      <IgnoreUnresolvedVariables>true</IgnoreUnresolvedVariables>
      <AssignTo createNew="false" transport="http" type="request"/>
    </AssignMessage>

    Step 2: an OAuth token endpoint and token verification

    Create a separate proxy for issuing tokens. It has no backend: the OAuthV2 policy answers the request itself.

    <OAuthV2 name="OAuth-GenerateAccessToken" continueOnError="false" enabled="true">
      <DisplayName>OAuth-GenerateAccessToken</DisplayName>
      <Operation>GenerateAccessToken</Operation>
      <ExpiresIn>1800000</ExpiresIn>
      <SupportedGrantTypes>
        <GrantType>client_credentials</GrantType>
      </SupportedGrantTypes>
      <GenerateResponse enabled="true"/>
    </OAuthV2>

    ExpiresIn is 1,800,000 ms, thirty minutes. The token endpoint proxy exposes it on a conditional flow:

    <ProxyEndpoint name="default">
      <PreFlow name="PreFlow">
        <Request>
          <Step><Name>SA-ProtectTokenEndpoint</Name></Step>
        </Request>
      </PreFlow>
      <Flows>
        <Flow name="GetAccessToken">
          <Condition>proxy.pathsuffix == "/token" AND request.verb == "POST"</Condition>
          <Request>
            <Step><Name>OAuth-GenerateAccessToken</Name></Step>
          </Request>
        </Flow>
      </Flows>
      <HTTPProxyConnection>
        <BasePath>/oauth</BasePath>
      </HTTPProxyConnection>
      <RouteRule name="NoRoute"/>
    </ProxyEndpoint>

    A token endpoint is a credential-guessing target, so it gets its own rate limit first (the traffic management lesson covers SpikeArrest in depth):

    <SpikeArrest name="SA-ProtectTokenEndpoint" continueOnError="false" enabled="true">
      <DisplayName>SA-ProtectTokenEndpoint</DisplayName>
      <Rate>30pm</Rate>
      <UseEffectiveCount>true</UseEffectiveCount>
    </SpikeArrest>

    On the API proxy, verification is a single policy placed in the request PreFlow:

    <OAuthV2 name="OAuth-VerifyAccessToken" continueOnError="false" enabled="true">
      <DisplayName>OAuth-VerifyAccessToken</DisplayName>
      <Operation>VerifyAccessToken</Operation>
    </OAuthV2>

    By default it reads the Authorization: Bearer ... header. To require a scope, add <Scope>orders.read</Scope>; verification passes if at least one listed scope is present in the token. Scopes are tied to API products, which the API design and products lesson covers.

    Step 3: JWT verification for tokens from an identity provider

    <VerifyJWT name="VJWT-VerifyIdpToken" continueOnError="false" enabled="true">
      <DisplayName>VJWT-VerifyIdpToken</DisplayName>
      <Algorithm>RS256</Algorithm>
      <PublicKey>
        <JWKS uri="https://idp.example.com/.well-known/jwks.json"/>
      </PublicKey>
      <Issuer>https://idp.example.com/</Issuer>
      <Audience>orders-api</Audience>
      <TimeAllowance>30s</TimeAllowance>
      <IgnoreUnresolvedVariables>false</IgnoreUnresolvedVariables>
    </VerifyJWT>

    No Source element means the policy reads the Authorization header and strips the Bearer prefix. The Issuer and Audience checks are what stop a valid token meant for a different API from being accepted. After this policy runs, jwt.VJWT-VerifyIdpToken.decoded.claim.sub holds the subject; the docs recommend the decoded.claim.* variables for reading claims.

    Step 4: threat protection

    <JSONThreatProtection name="JTP-LimitBody" continueOnError="false" enabled="true">
      <DisplayName>JTP-LimitBody</DisplayName>
      <Source>request</Source>
      <ArrayElementCount>100</ArrayElementCount>
      <ContainerDepth>8</ContainerDepth>
      <ObjectEntryCount>50</ObjectEntryCount>
      <ObjectEntryNameLength>64</ObjectEntryNameLength>
      <StringValueLength>1000</StringValueLength>
    </JSONThreatProtection>

    The limits are illustrative. Derive real ones from the largest legitimate payload your API accepts, plus headroom. Elements you omit are not enforced, so an empty policy protects nothing. The policy runs only when the request Content-Type is application/json. A caller who sends the same body with another content type skips it, so also reject unexpected content types before the backend sees them.

    For values rather than structure, RegularExpressionProtection screens a message part against patterns:

    <RegularExpressionProtection name="REP-QueryParams" continueOnError="false" enabled="true">
      <DisplayName>REP-QueryParams</DisplayName>
      <Source>request</Source>
      <QueryParam name="status">
        <Pattern><![CDATA[(?i)(<\s*script\b|drop\s+table)]]></Pattern>
      </QueryParam>
    </RegularExpressionProtection>

    Wrap patterns containing <, >, & or quotes in CDATA, as above. Avoid greedy .* constructs on large payloads: Apigee's antipattern guidance explains that backtracking makes them slow. The policy page does not say whether a pattern must match the whole value or only part of it, so test each pattern against sample values in Debug before relying on it. Treat this policy as a coarse filter, not a substitute for parameterised queries in the backend. For XML bodies, XMLThreatProtection offers limits on names, structure and values in the same spirit.

    Step 5: CORS for browser callers

    Browsers enforce the same-origin policy; CORS headers tell them which cross-origin calls are allowed.

    <CORS name="CORS-Orders" continueOnError="false" enabled="true">
      <DisplayName>CORS-Orders</DisplayName>
      <AllowOrigins>https://shop.example.com, https://admin.example.com</AllowOrigins>
      <AllowMethods>GET, POST, PUT, DELETE</AllowMethods>
      <AllowHeaders>origin, accept, content-type, authorization</AllowHeaders>
      <ExposeHeaders>x-request-id</ExposeHeaders>
      <MaxAge>3600</MaxAge>
      <AllowCredentials>false</AllowCredentials>
      <GeneratePreflightResponse>true</GeneratePreflightResponse>
      <IgnoreUnresolvedVariables>true</IgnoreUnresolvedVariables>
    </CORS>

    List explicit origins. The docs warn that * effectively disables same-origin protection, and the policy's default template (which echoes {request.header.origin}) allows any caller who sends an Origin header. Note that authorization must be in AllowHeaders for bearer tokens to work from a browser.

    Preflight requests use the HTTP OPTIONS method and carry no credentials, so authentication must not block them. The CORS policy can generate the preflight response, but you still have to route OPTIONS requests to it; the documented pattern is a conditional flow plus a route rule with no target. Combined with the earlier policies, the whole ProxyEndpoint looks like this:

    <ProxyEndpoint name="default">
      <PreFlow name="PreFlow">
        <Request>
          <Step><Name>SA-ProtectBackend</Name></Step>
          <Step>
            <Name>VK-VerifyAPIKey</Name>
            <Condition>request.verb != "OPTIONS"</Condition>
          </Step>
          <Step>
            <Name>JTP-LimitBody</Name>
            <Condition>request.verb = "POST" OR request.verb = "PUT"</Condition>
          </Step>
          <Step><Name>AM-RemoveApiKey</Name></Step>
          <Step>
            <Name>CORS-Orders</Name>
            <Condition>request.verb != "OPTIONS"</Condition>
          </Step>
        </Request>
      </PreFlow>
      <Flows>
        <Flow name="OptionsPreFlight">
          <Condition>request.verb == "OPTIONS" AND request.header.origin != null AND request.header.Access-Control-Request-Method != null</Condition>
          <Request>
            <Step><Name>CORS-Orders</Name></Step>
          </Request>
        </Flow>
      </Flows>
      <HTTPProxyConnection>
        <BasePath>/orders/v1</BasePath>
      </HTTPProxyConnection>
      <RouteRule name="NoRoute">
        <Condition>request.verb == "OPTIONS" AND request.header.origin != null AND request.header.Access-Control-Request-Method != null</Condition>
      </RouteRule>
      <RouteRule name="default">
        <TargetEndpoint>default</TargetEndpoint>
      </RouteRule>
    </ProxyEndpoint>

    (SA-ProtectBackend is the spike arrest policy from the traffic management lesson.) Keep the default route last, because route rules are evaluated in order. The docs' walkthrough is loose about whether the policy for normal requests sits in the ProxyEndpoint or the TargetEndpoint request PreFlow, so confirm in a trace that the CORS headers appear on both preflight and normal responses. If you use a cache policy, the docs say the response of the CORS policy must not be cached.

    Step 6: test with curl

    Register an app (see the products lesson) to get a consumer key and secret, then request a token. Hostnames and credentials below are placeholders.

    # 1. Request a token (client credentials). -u sends the id:secret as HTTP Basic.
    curl -s -X POST "https://api.example.com/oauth/token" \
      -u "$CLIENT_ID:$CLIENT_SECRET" \
      -H "Content-Type: application/x-www-form-urlencoded" \
      -d "grant_type=client_credentials"

    A successful response is JSON containing access_token, token_type, expires_in (seconds), client_id and the approved API product list. Copy the token:

    # 2. Call the protected API with the token
    curl -i "https://api.example.com/orders/v1/orders/1001" \
      -H "Authorization: Bearer $ACCESS_TOKEN"
    
    # 3. Negative tests: each should be rejected at the gateway, never reaching the backend
    curl -i "https://api.example.com/orders/v1/orders/1001"                                  # no token
    curl -i "https://api.example.com/orders/v1/orders/1001" -H "Authorization: Bearer abc"   # garbage token
    curl -i "https://api.example.com/orders/v1/orders" -H "x-apikey: wrong-key"              # bad API key (key-protected proxy)

    Rejected requests return a fault JSON body. In fault rules, match on the errorcode (for example keymanagement.service.invalid_access_token), not the faultstring, which the docs say can change. Verify in Debug (Trace) that the backend was not called for the failing cases.

    TLS and mTLS on both sides of the gateway

    Two separate TLS connections exist, and they are configured in different places.

    Northbound TLS terminates before Apigee; southbound TLS and mTLS are configured on the targetPartner client to Load balancer (northbound); Load balancer to Apigee proxy; Apigee proxy to Legacy backend (mTLS)Partner client: presents a client certificatePartner clientpresents a clientcertificateLoad balancer: server TLS, client mTLSLoad balancerserver TLS,client mTLSApigee proxy: target SSLInfo, keystoresApigee proxytarget SSLInfo,keystoresLegacy backend: needs a client certificateLegacy backendneeds a clientcertificatenorthboundmTLS

    How to read it: you decide separately what the client must prove (northbound) and what Apigee must prove to the backend (southbound).

    Northbound (client to Apigee). In a typical Apigee X deployment, client traffic arrives through a Google Cloud load balancer. Google's load balancer documentation describes mutual TLS on the target HTTPS proxy of the Application Load Balancers, with two validation modes: reject invalid or missing client certificates, or allow them through and pass the validation result to the backend in custom headers. In the lenient mode the decision is yours: a policy in the proxy must read those headers and reject. The load balancer can also forward a certificate fingerprint and selected certificate fields. Check the load balancing docs for the exact header names and current limits before designing around them.

    Southbound (Apigee to the backend). Configure TLS on the target with the SSLInfo block. For mutual TLS set ClientAuthEnabled and reference a keystore (Apigee's private key and certificate) and a truststore (the certificates it trusts):

    <TargetEndpoint name="default">
      <HTTPTargetConnection>
        <URL>https://orders.internal.example.com/orders</URL>
        <SSLInfo>
          <Enabled>true</Enabled>
          <Enforce>true</Enforce>
          <ClientAuthEnabled>true</ClientAuthEnabled>
          <KeyStore>ref://ordersKeystoreRef</KeyStore>
          <KeyAlias>apigee-to-orders</KeyAlias>
          <TrustStore>ref://ordersTruststoreRef</TrustStore>
          <Protocols>
            <Protocol>TLSv1.2</Protocol>
            <Protocol>TLSv1.3</Protocol>
          </Protocols>
        </SSLInfo>
      </HTTPTargetConnection>
    </TargetEndpoint>

    Three details from the docs matter operationally. Enforce set to true makes connections fail for invalid, expired, self-signed or hostname-mismatched certificates; without it the result depends on IgnoreValidationErrors. Apigee does not support TLS 1.0 or 1.1 on targets. And always reference the keystore and truststore through ref:// references: you cannot add a certificate to an existing keystore, so rotation means creating a new store and repointing the reference, with no proxy redeployment. A directly named store that is replaced in place may need support intervention to be picked up. The same SSLInfo block can also be set on a target server definition; the docs do not state which one wins if both exist, so configure TLS in one place.

    Access control, secrets and configuration

    IP rules. The AccessControl policy allows or denies by client IP address with IPRules, MatchRule and SourceAddress (with a mask). Google recommends Cloud Armor for IP-based and geo-based control where possible, and AccessControl where it is not.

    Key value maps (KVMs). Store environment-specific configuration and secrets (backend credentials, signing keys) in KVMs rather than policy XML, so they stay out of the proxy bundle and source control. The docs state that in Apigee all KVM entries at proxy, environment and organisation scope are encrypted with the Cloud KMS key supplied when the organisation was provisioned (AES256). Two habits help: turn on masking so values display as asterisks in the UI, and read values into variables whose names start with private., which keeps them out of Debug sessions. KVMs can be scoped to a proxy, an environment or the organisation; the Apigee UI manages environment scope only.

    Secret Manager. The pages reviewed for this lesson do not describe a built-in Secret Manager policy. If your organisation requires Secret Manager as the system of record, check the current Apigee documentation for a supported integration before designing around one.

    Apigee Advanced API Security

    Advanced API Security is a paid add-on that watches your traffic and configuration. Its parts, as the docs describe them: abuse detection (rules based on machine learning that group suspicious events into incidents), security reports, risk assessment (scores for proxy configurations with recommendations, such as missing auth policies or continueOnError set to true on auth policies), security actions (for example, deny requests from an abusive IP address) and security alerts. It is not supported for APIs running under the Apigee Adapter for Envoy. Treat it as a detection and hygiene layer on top of the policies above, not a replacement for them.

    Use cases

    1. Partner API with API keys. A handful of known partners read order status. Use VerifyAPIKey over TLS, a quota per app taken from the API product, an IP allow-list for partners with fixed addresses, and strip the key before the backend. Cheap to onboard, easy to revoke per app.
    2. Machine-to-machine with OAuth client credentials. A fulfilment system calls the Orders API on a schedule. It exchanges its client ID and secret at the token endpoint and calls with a thirty-minute token. Scopes restrict it to orders.read. A leaked token expires; a leaked secret is rotated per app.
    3. Mobile app with short-lived tokens. The app signs the user in through an identity provider (authorization code with a code challenge, handled by the provider). The proxy runs VerifyJWT against the provider's JWKS and checks Issuer and Audience. The app never holds a long-lived secret. An API key embedded in a mobile binary can be extracted, so it is never the only protection.
    4. Protecting a legacy backend with mTLS. An old orders service only accepts calls carrying a certificate. Apigee presents its keystore certificate (southbound mTLS) and Enforce is on. Nothing else can reach the service through the gateway path, and the backend can also restrict its network to Apigee's egress.
    5. Browser single-page app. A web front end on another origin calls the API. Explicit origins in CORS, preflight handled in a conditional flow, and the authorization header allowed.

    Trade-offs at a glance

    Mechanism Strength Revocation Best for Main weakness
    API key Identifies an app Per app, within the cache window Known partners, usage tracking Static shared secret, no claims
    OAuth client credentials App identity with scopes, short-lived tokens Per token and per app Server-to-server Needs a token endpoint and client-side token caching
    JWT verified in Apigee Self-contained claims, no call to the issuer per request Wait for expiry, or short lifetimes Tokens from an existing identity provider Key and claim validation must be exact
    mTLS Strong transport identity Certificate revocation or removal from the truststore Backend leg, high-trust partners Certificate lifecycle work
    IP allow-list Network-level filter Immediate Fixed-address partners Addresses change, shared egress IPs

    In practice: opening an order API to a retail partner

    NOTE

    A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.

    The situation

    A retailer's Orders service has served only its own web front end. A logistics partner now needs read access to order status, and the mobile team wants to call the same API. The service currently trusts anything that reaches it over a private network and has no per-caller identity, so a single misbehaving caller could slow every consumer. A security review asks: who is calling, what can they see, and what stops a bad request reaching the service?

    Starting architecture

    Starting point: traffic reaches the orders service with no gateway checksWeb front end to Load balancer; Logistics partner to Load balancer; Load balancer to Orders service (no checks)Web front endWeb front endLogistics partnerLogisticspartnerLoad balancer: TLS onlyLoad balancerTLS onlyOrders service: trusts the networkOrders servicetrusts thenetworkno checks

    What changes

    1. Put an Apigee proxy in front of the service. Create one API product per audience (partner read-only, mobile) and register the partner as a developer app.
    2. Give the partner client credentials: a token endpoint proxy with a rate limit, and VerifyAccessToken on the API proxy with the orders.read scope.
    3. For the mobile app, verify tokens from the company identity provider with VerifyJWT (JWKS, Issuer, Audience).
    4. Add SpikeArrest first and Quota from the API product after authentication, then JSONThreatProtection on the write endpoints the mobile app uses.
    5. Strip credentials before the backend and pass a verified identity header (for example x-client-id from the client_id flow variable). The service now authorises by that identity and checks object ownership itself.
    6. Turn on southbound TLS with Enforce, and restrict the service's network so only the gateway path can reach it.
    7. Put Cloud Armor on the load balancer for IP and WAF rules, and keep backend credentials in an environment-scoped KVM.
    sequenceDiagram participant P as Partner system participant T as Token proxy participant A as Orders API proxy participant S as Orders service P->>T: POST /oauth/token (client id and secret) T-->>P: access token (30 minutes) P->>A: GET /orders/v1/orders/1001 with bearer token A->>A: VerifyAccessToken, then Quota A->>S: request plus verified client id, over TLS S->>S: check this client may see order 1001 S-->>A: 200 order status A-->>P: 200 order status

    How to read it: the gateway proves the partner is who it claims and within its plan; the service still decides whether this partner may see this order.

    Result

    The partner can read order status within a documented quota, and every call is attributable to an app. Calls without credentials, with expired tokens or with oversized bodies are rejected at the gateway, visible as 4xx counts in analytics and never reaching the service. Cutting off the partner is one administrative action on its app, and an individual token can be revoked with InvalidateToken, subject to the three-minute cache window. The reviewer's three questions now have concrete answers, and the service's own object-level checks are still required and still tested.

    Architect's notes

    • Fail closed. Auth policies should keep continueOnError false. Apigee's own risk assessment flags auth policies that continue on error.
    • Shared flows for the baseline. Put verification, spike arrest and error shaping into a shared flow attached by a flow hook or callout so every proxy gets the same baseline. A new team should have to opt out of security, not opt in.
    • Do not trust headers you did not set. Remove any inbound x-client-id style header before you set your own, or callers can forge identity.
    • Short tokens beat clever revocation. Because of caching, instant revocation is not on offer; short lifetimes are the lever.
    • Treat the token endpoint as an attack surface. Rate-limit it, return uniform errors and log failures.
    • Cost. Some of these policies are classed Extensible and may have licensing or utilisation implications depending on your Apigee licence, and Advanced API Security is a paid add-on. Check the policy type before standardising.
    • When not to add a layer. An internal, low-risk, read-only API may not need threat protection on every route. Spend complexity where a request can do damage.

    Common mistakes

    • Treating an API key as authentication for sensitive data. It identifies an app and can be copied. Use OAuth or JWT for anything that needs claims, scopes or short lifetimes.
    • Verifying a JWT without Issuer and Audience. A signature alone proves the token is genuine, not that it was meant for this API. Add both checks.
    • Using DecodeJWT for decisions. It does not check the signature. Use VerifyJWT.
    • Expecting BasicAuthentication to protect the proxy. It encodes and decodes credentials only. Build verification explicitly.
    • Blocking CORS preflight with authentication. Skip authentication for OPTIONS (request.verb != "OPTIONS") and answer preflight in a conditional flow.
    • Wildcard CORS origins with credentials. List explicit origins.
    • Assuming JSON threat protection covers every request. It runs only for application/json. Reject unexpected content types as well.
    • Greedy regular expressions on large bodies. Prefer anchored, specific patterns, and keep regex protection as a coarse filter.
    • Letting the backend see credentials. Strip the API key (and the bearer token when the backend does not need it).
    • Secrets in policy XML or target URLs. Use encrypted KVMs, masking and the private. prefix.
    • Rotating certificates in place. Create a new keystore or truststore and repoint the ref:// reference.
    • Believing the gateway fixes object-level authorisation. The service must still check ownership of each object.

    Key takeaways

    • Security at the gateway is layered; order the checks so the cheapest rejection happens first.
    • API keys identify apps; OAuth and JWT give claims, scopes and short lifetimes.
    • One OAuthV2 policy issues and verifies tokens through its Operation element; ExpiresIn is in milliseconds.
    • VerifyJWT needs Issuer and Audience checks and, for external identity providers, a JWKS; DecodeJWT never verifies.
    • Northbound mTLS is handled at the load balancer; southbound mTLS is the target SSLInfo block with keystore and truststore references.
    • JSON threat protection only fires for application/json, and omitted limits are not enforced.
    • Keep secrets in encrypted KVMs and out of policy XML and Debug output.
    • The backend still owns object-level authorisation and business rules.

    Practice exercises

    Starter (about 30 minutes): In an Apigee evaluation or test organisation, create a proxy to a public mock backend. Add VerifyAPIKey reading an x-apikey header, create an API product and developer app, and call the proxy with a good key, a wrong key and no key. In Debug, compare the fault errorcode values.

    Intermediate (about 2 hours): Build the token endpoint proxy and a protected API proxy using OAuthV2 client credentials. Request a token with curl, call the API, then add a scope requirement and show a call with the wrong scope being rejected. Add JSONThreatProtection limits for a POST route and craft a payload that violates each limit in turn, noting which request header makes the policy skip.

    Advanced (half a day): Design and document the full layered defence for the Orders API: Cloud Armor rules, spike arrest and quota values, authentication per caller type (partner, machine, mobile), CORS origins, southbound mTLS with referenced keystores, and an encrypted KVM for backend credentials. Write a certificate-rotation runbook (create new store, repoint reference, verify, remove old) and a test plan with at least ten negative test cases. Explain which OWASP items remain the backend's responsibility.

    Official documentation

    Enroll to keep reading

    Lesson 5 of 11 · Apigee API Management: Beginner to Advanced

    Secure Apigee APIs in layers: API keys, OAuth 2.0 client credentials, JWT verification, mTLS, CORS and threat protection, with working policy XML and curl.

    In the rest of this lesson

    • How the request-validation order works
    • How to implement it, step by step
    • TLS and mTLS on both sides of the gateway
    • Access control, secrets and configuration
    • Apigee Advanced API Security
    • Use cases

    Enrolling is free. It unlocks every lesson in Apigee API Management: Beginner to Advanced, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments