Olympus Docs
CookbookDefensive security

Restrict by network / IP range

Only allow auth from specific networks

For internal-tool deployments, you might want auth to only work from corporate networks or VPN.

Approach 1: Caddy IP allowlist

Restrict at the edge:

ciam.your-domain.com {
  @corporate remote_ip 203.0.113.0/24 198.51.100.0/24
  handle @corporate {
    reverse_proxy hera:3000
  }
  
  respond 403 "Access restricted to corporate network"
}

Cloudflare in front? Use CF-Connecting-IP:

ciam.your-domain.com {
  @corporate {
    header CF-Connecting-IP "203.0.113.*"
  }
  ...
}

(Use Caddy's client_ip_headers to make remote_ip look at the real client IP.)

Approach 2: VPN-only

ciam.your-domain.com {
  @vpn remote_ip 10.8.0.0/24    # VPN subnet
  handle @vpn {
    reverse_proxy hera:3000
  }
  respond 403
}

Users must connect to VPN to even reach the login page. Outside VPN: 403.

Pros: simplest defense. Cons: VPN itself becomes the trust boundary.

Approach 3: App-level check

If you want some endpoints public (login) and others restricted:

// Athena middleware
const ip = req.headers["x-real-ip"];
const allowed = ["203.0.113.0/24", "198.51.100.0/24"];
if (!allowed.some(cidr => ipInCidr(ip, cidr))) {
  return res.status(403).send("Not from allowed network");
}

Granularity per route.

Approach 4: Risk-based step-up

Allow access from anywhere, but require MFA / stronger auth from outside the allowlist:

# kratos.yml
session:
  required_aal:
    default: aal1
    when:
      - condition: "request.ip not in 203.0.113.0/24"
        require: aal2

(This is a sketch, actual Kratos config uses webhook-based decisions.)

Pre-login hook:

export async function POST(req: Request) {
  const { request_ip } = await req.json();
  const inCorpNetwork = isCorpNetwork(request_ip);
  return Response.json({
    require_aal: inCorpNetwork ? "aal1" : "aal2",
  });
}

User from corp: password only. User from coffee shop: password + MFA.

Better UX than hard block.

Approach 5: Geo-restriction

Block by country (e.g., "no logins from sanctioned countries"):

@geo_blocked {
  expression {http.request.header.CF-IPCountry} in ["XX", "YY"]
}
respond @geo_blocked 403

Requires Cloudflare's IP-Country header or a Caddy GeoIP plugin.

Heads up: false positives (VPN-using legitimate users in blocked countries). Tune carefully.

CIDR maintenance

Corporate IP ranges change. Keep your allowlist in a Caddy snippet:

(corp_ips) {
  @corp remote_ip 203.0.113.0/24 198.51.100.0/24 10.0.0.0/8
}

ciam.your-domain.com {
  import corp_ips
  handle @corp { ... }
}

Update one place, all sites reflect.

For dynamic / API-driven IPs (e.g., from a IPAM):

# cron: regenerate ip-allowlist.caddy hourly from API
curl https://your-ipam/api/corp-cidrs > /etc/caddy/ip-allowlist.caddy
caddy reload

What about mobile / remote workers?

Hard IP block excludes work-from-home, business travel. Options:

  • VPN: workers connect to VPN, get a known IP.
  • Cloud Access Security Broker (CASB) like Zscaler, gives users a fixed egress IP.
  • Risk-based: allow but force MFA from unknown IPs.

For modern flexible work, hard IP allowlist is often impractical. Lean toward step-up.

Logging

Log block events:

log {
  format json
  level INFO
  output file /var/log/caddy/blocked.log
}

@blocked not remote_ip 203.0.113.0/24
handle @blocked {
  log_name blocked-attempts
  respond 403
}

Review periodically, are legit users hitting 403? Tune allowlist.

Bypass for emergencies

Don't lock yourself out. Have a bypass:

  • Admin IP allowlist that's always allowed.
  • Break-glass path (only specific URL) that requires a strong secret.
  • Out-of-band recovery via SSH to host, direct DB access.

Document in your runbook.

On this page