Skip to content

Instantly share code, notes, and snippets.

@lcgitsup
Created March 19, 2026 08:39
Show Gist options
  • Select an option

  • Save lcgitsup/c398a3991d90c888580cdf19c130a07f to your computer and use it in GitHub Desktop.

Select an option

Save lcgitsup/c398a3991d90c888580cdf19c130a07f to your computer and use it in GitHub Desktop.
How to bulk create WireGuard clients on Unifi UDM Pro via API (Python + reverse engineering)

How to bulk create WireGuard clients on UDM Pro via API (Python + reverse engineering)

I needed to deploy WireGuard VPN to 48 Windows machines on an Active Directory domain. Creating clients one by one in the UniFi UI was not an option, so I reverse engineered the API using Firefox DevTools. Sharing everything here since there is almost no documentation on this.


The problem

UniFi has no official bulk client creation feature. The documented API paths (/proxy/network/api/v2/...) return HTTP 400 on firmware Network 10.x. It took a lot of trial and error to find the correct endpoints.


Key discoveries

1. Login requires Origin and Referer headers

Without these two headers, the login endpoint returns HTTP 403 — no error message, just a flat rejection. This is an undocumented CSRF protection at the nginx reverse proxy level.

session.post("https://YOUR_UDM_IP/api/auth/login",
    json={"username": "local_user", "password": "password"},
    headers={
        "Content-Type": "application/json",
        "Origin":       "https://YOUR_UDM_IP",
        "Referer":      "https://YOUR_UDM_IP/"
    },
    verify=False)

Your account must be a local account (no email, "Restrict to Local Access Only" checked in UniFi OS → Settings → Admins). A cloud/SSO Ubiquiti account will always return 403 on the local API.


2. The API path is v2/api, not api/v2

This wasted hours. On firmware 10.x the correct base path is:

/proxy/network/v2/api/site/default/wireguard/

Not /proxy/network/api/v2/... which is what most community posts reference and which returns HTTP 400 on this firmware.


3. The creation endpoint is /users/batch

Discovered by watching Firefox DevTools Network tab while manually creating a client in the UI:

POST /proxy/network/v2/api/site/default/wireguard/{SERVER_ID}/users/batch

The payload must be a JSON array (not an indexed object). Each item includes the keypair generated client-side:

[
  {
    "allowed_ips":   [],
    "interface_ip":  "192.168.6.20",
    "name":          "MACHINE-NAME",
    "preshared_key": "",
    "private_key":   "BASE64_PRIVATE_KEY",
    "public_key":    "BASE64_PUBLIC_KEY"
  }
]

The UDM Pro never stores the private key. It must be generated locally and kept for the .conf file. The UI will show a warning "The configuration file needs to be manually created for clients with a custom public key" — this is expected and normal.


4. Generate WireGuard keys in pure Python (no wg.exe needed)

WireGuard uses Curve25519. The Python cryptography library generates compatible keypairs:

from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
    Encoding, PublicFormat, PrivateFormat, NoEncryption)
import base64

def generate_wireguard_keypair():
    pk   = X25519PrivateKey.generate()
    priv = base64.b64encode(
        pk.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())).decode()
    pub  = base64.b64encode(
        pk.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)).decode()
    return priv, pub

5. Get the server info from networkconf (classic API)

The server public key, DNS, subnet and port are in the classic v1 API:

GET /proxy/network/api/s/default/rest/networkconf

Look for the entry with "vpn_type": "wireguard-server":

{
  "wireguard_public_key": "SERVER_PUBLIC_KEY",
  "ip_subnet":            "192.168.6.1/24",
  "local_port":           51820,
  "dhcpd_dns_1":          "192.168.X.X",
  "dhcpd_dns_2":          "192.168.X.X"
}

The public endpoint IP is shown in UniFi UI → Settings → VPN → VPN Server → Server Address.


Full script

import requests, urllib3, json, os, base64, time, subprocess, sys

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# ============================================================
#  CONFIGURATION
# ============================================================
UDM_IP     = "YOUR_UDM_IP"
USERNAME   = "YOUR_LOCAL_USER"
PASSWORD   = "YOUR_PASSWORD"
SERVER_ID  = "YOUR_SERVER_ID"    # _id from GET /rest/networkconf
SITE       = "default"
BASE       = f"https://{UDM_IP}"
OUTPUT_DIR = "./wg_configs"

SERVER_PUBLIC_KEY = "YOUR_SERVER_PUBLIC_KEY"
SERVER_ENDPOINT   = "YOUR_PUBLIC_IP:51820"
SERVER_DNS        = "192.168.X.X,192.168.X.X"
ALLOWED_IPS       = "0.0.0.0/0"    # full tunnel, or "192.168.0.0/16" for split
IP_PREFIX         = "192.168.6."
START_SUFFIX      = 10

clients = [
    "MACHINE-001",
    "MACHINE-002",
    # add your machine names here
]

# ============================================================
#  KEY GENERATION
# ============================================================
def generate_keypair():
    try:
        from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
        from cryptography.hazmat.primitives.serialization import (
            Encoding, PublicFormat, PrivateFormat, NoEncryption)
    except ImportError:
        subprocess.check_call([sys.executable, "-m", "pip", "install", "cryptography", "-q"])
        from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
        from cryptography.hazmat.primitives.serialization import (
            Encoding, PublicFormat, PrivateFormat, NoEncryption)
    pk   = X25519PrivateKey.generate()
    priv = base64.b64encode(
        pk.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())).decode()
    pub  = base64.b64encode(
        pk.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)).decode()
    return priv, pub

# ============================================================
#  MAIN
# ============================================================
def run():
    os.makedirs(OUTPUT_DIR, exist_ok=True)

    s = requests.Session()
    r = s.post(f"{BASE}/api/auth/login",
        json={"username": USERNAME, "password": PASSWORD},
        headers={"Origin": BASE, "Referer": f"{BASE}/"},
        verify=False)
    csrf = r.headers.get("X-Csrf-Token") or r.headers.get("x-updated-csrf-token") or ""
    s.headers.update({
        "X-Csrf-Token": csrf,
        "Origin":       BASE,
        "Referer":      f"{BASE}/",
        "Content-Type": "application/json"
    })
    print(f"Login: {r.status_code}")

    batch_url = f"{BASE}/proxy/network/v2/api/site/{SITE}/wireguard/{SERVER_ID}/users/batch"
    users_url = f"{BASE}/proxy/network/v2/api/site/{SITE}/wireguard/{SERVER_ID}/users"

    existing   = s.get(users_url, verify=False).json()
    used_ips   = {u["interface_ip"] for u in existing}
    used_names = {u["name"] for u in existing}
    print(f"Existing users: {len(existing)}")

    ok = 0; errs = 0; skipped = 0
    current_suffix = START_SUFFIX

    for name in clients:
        while f"{IP_PREFIX}{current_suffix}" in used_ips:
            current_suffix += 1

        if name in used_names:
            print(f"  SKIP {name} (already exists)")
            skipped += 1
            continue

        target_ip = f"{IP_PREFIX}{current_suffix}"
        priv_key, pub_key = generate_keypair()

        payload = [{
            "allowed_ips":   [],
            "interface_ip":  target_ip,
            "name":          name,
            "preshared_key": "",
            "private_key":   priv_key,
            "public_key":    pub_key
        }]

        r2 = s.post(batch_url, json=payload, verify=False)

        if r2.status_code in (200, 201):
            resp_list = r2.json()
            created   = resp_list[0] if isinstance(resp_list, list) else {}
            print(f"  OK: {name} -> {target_ip} | _id: {created.get('_id','?')[:12]}")

            conf = f"""[Interface]
PrivateKey = {priv_key}
Address = {target_ip}/32
DNS = {SERVER_DNS}

[Peer]
PublicKey = {SERVER_PUBLIC_KEY}
Endpoint = {SERVER_ENDPOINT}
AllowedIPs = {ALLOWED_IPS}
PersistentKeepalive = 25
"""
            with open(os.path.join(OUTPUT_DIR, f"{name}.conf"), "w") as f:
                f.write(conf)
            ok += 1
        else:
            print(f"  ERROR {name}: HTTP {r2.status_code} | {r2.text[:150]}")
            errs += 1

        used_ips.add(target_ip)
        current_suffix += 1
        time.sleep(0.3)

    final = s.get(users_url, verify=False).json()
    print(f"\nUsers in UDM Pro: {len(final)}")
    print(f"DONE: {ok} created | {errs} errors | {skipped} skipped")

if __name__ == "__main__":
    run()

Endpoint reference table

Endpoint Method Description
/api/auth/login POST Login (Origin + Referer required)
/proxy/network/api/s/default/rest/networkconf GET Server config, public key, DNS
/proxy/network/v2/api/site/default/wireguard/{id}/users GET List existing clients
/proxy/network/v2/api/site/default/wireguard/{id}/users/batch POST Create clients

Common errors and fixes

Error Cause Fix
HTTP 403 on login Missing Origin/Referer headers Add both headers
HTTP 403 with valid credentials Cloud/SSO account Create a local account (no email)
HTTP 400 on /users/batch Payload is object {} not array [] Use a JSON array
HTTP 405 on /users/batch Wrong HTTP method Use POST only
HTTP 400 on /proxy/network/api/v2/... Wrong path order Use /proxy/network/v2/api/...

Environment

  • Firmware: UniFi Network 10.1.85 on UDM Pro
  • Python 3.x with requests and cryptography libraries
  • Tested on 48 Windows machines with Active Directory GPO deployment

Happy to answer questions. Let me know if it works on other firmware versions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment