Some services need to support different groups of users with different types of credentials. Internal teams might use static IPs, but external users authenticate using Basic Auth. With ngrok’s Traffic Policy engine, you can let both types of users through safely without making any changes to your upstream service. This example walks you through how to apply multiple authentication layers to a single endpoint using action result variables and expressions that mimic an OR operator. You’ll allow requests from trusted IPs OR those with valid Basic Auth credentials, and then reject all others.

1. Start an endpoint for your service

Start an internal Agent Endpoint, replacing $PORT based on where your service listens. You can also use one of our SDKs or the Kubernetes Operator.
ngrok http $PORT --url https://service.internal

2. Reserve a domain

Navigate to the Domains section of the ngrok dashboard and click New + to reserve a free static domain like https://your-service.ngrok.app or a custom domain you already own. We’ll refer to this domain as $NGROK_DOMAIN from here on out.

3. Create a Cloud Endpoint

Navigate to the Endpoints section of the ngrok dashboard, then click New + and Cloud Endpoint. In the URL field, enter the domain you just reserved to finish creating your Cloud Endpoint.

4. Add layers of authentication with Traffic Policy

While still viewing your new Cloud Endpoint in the dashboard, copy and paste the policy below into the Traffic Policy editor. You will need to change:
  • $TRUSTED_IP_FOO/$TRUSTED_IP_BAR: Replace with public IPs of those who should have access to your service or an IP policy in your ngrok dashboard.
on_http_request:
  # Check whether a request's IP matches your allow list.
  - actions:
      - type: restrict-ips
        config:
          # By specifying `false`, you allow the action to run and generate
          # response variables, like `action.ngrok.restrict_ips.action, which
          # allow you to more precisely customize how your policy responds.
          enforce: false
          allow:
            - $TRUSTED_IP_FOO/32
            - $TRUSTED_IP_BAR/32

  # If IP restriction fails, try Basic Auth
  - expressions:
      - actions.ngrok.restrict_ips.action == "deny"
    actions:
      - type: basic-auth
        config:
          enforce: false
          credentials:
            - user:password1
            - admin:password2

  # Forward the request if either the IP restriction OR the Basic Auth actions were successful.
  - expressions:
      - "actions.ngrok.restrict_ips.action == 'allow' || actions.ngrok.basic_auth.credentials.authorized == true"
    actions:
      - type: forward-internal
        config:
          url: https://service.internal

  # Catch-all custom response for any unauthenticated requests.
  - actions:
      - type: custom-response
        config:
          status_code: 401
          headers: {}
          body: >-
            <!DOCTYPE html>
            <html lang="en">
            <head>
              <meta charset="UTF-8" />
              <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
              <title>Unauthorized</title>
              <style>
                body{margin:0;height:100vh;background-color:#f9f9f9;display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif}.box{padding:2rem 3rem;border:2px solid #d9534f;border-radius:12px;background-color:#fff3f3;color:#d9534f;font-size:1.5rem;text-align:center;box-shadow:0 4px 12px rgba(0,0,0,.1)}
              </style>
            </head>
            <body>
              <div class="box">
                🚫 Access denied
              </div>
            </body>
            </html>
What’s happening here? The first section of this policy checks every HTTP request to see if its source IP matches one on your allow list for restrict-ips, but doesn’t actually enforce the restriction there. Instead, the expression that follows the first action checks whether the restrict-ips action result variable actions.ngrok.restrict_ips.action is set to allow, which means that it would’ve allowed the request directly if you had set enforce: true. If it’s false—the request didn’t match the IP allow list—then the policy then validates whether it contains a valid Basic Auth header. Next, the policy checks whether either IP restrictions OR Basic Auth were successful, and if so, forwards to your upstream service. A catch-all rule then sends a custom error response to all other requests.

6. Try out your endpoint

Visit the domain you reserved either in the browser or in the terminal using a tool like curl. You should see the app or service at the port connected to your internal Agent Endpoint. Add base64-encoded username:password pair as an Authorization: Bearer header to verify your basic-auth action works as expected.
curl https://$NGROK_DOMAIN \
  --header 'Authorization: Bearer $BASE64_CREDENTIALS'

What’s next?

  • Layer in more authentication mechanisms, like the oauth or basic-auth actions using the same pattern of checking the subsequent action result variables with an expression.
  • Use the action result variables and CEL interpolation to add specific error messages to your catch-all custom-response action.
  • View your traffic in Traffic Inspector to see requests that failed to authenticate but should have—for example, maybe a specific IP address is missing from your restrict-ips allow list or a user who doesn’t realize they’re using an invalid JWT token.