puddle.town

Scripting the Aruba Instant On API

I run Aruba (now HPE Networking) Instant On gear at home, and over the years I've given friendly names to most of the clients in the dashboard. The problem is that those names live in Instant On's cloud and nowhere else. There's no local API, no SNMP, no config export. If I want to line those names up against DHCP leases or an nmap sweep, I have to go read them off a web page.

The portal is backed by a REST API though, and the community reverse-engineered it years ago. It still works in 2026, with a couple of adjustments. Here's what I found, plus a small Python script that dumps a MAC-to-name table.

I should say up front that HPE's official position is that this API doesn't exist. It's undocumented and unsupported, and nothing stops them from changing or removing it tomorrow. Everything below is where things stood in July 2026.

What changed

Most of the groundwork was done back in 2022 by Luke Whitelock, in his writeup of the Instant On API and the PowerShell in his HuduAutomation repo. There's also a Python token-flow gist from crockk, and a Postman collection floating around. Following any of those verbatim no longer works.

The first thing that broke is the rebrand. portal.arubainstanton.com now throws a 308 redirect over to portal.instant-on.hpe.com. The SSO host is still sso.arubainstanton.com, for now. Rather than hardcoding any of that, you can pull settings.json from the portal, which conveniently describes itself: the SSO host, the OAuth client id, the redirect URI and the API base are all in there.

The second thing is that wiredClientSummary is gone. The old docs list it alongside clientSummary, one endpoint for wired clients and one for wireless, but the wired one returns a 404 now. These days clientSummary hands back everything, wired and wireless together, and each record carries both sets of fields. You can tell which is which by whether wirelessNetworkId is set.

The good news is that the x-ion-api-version: 7 header from the 2022 writeups is still accepted.

Authentication

It's OAuth2 authorization-code with PKCE, with one quirk: instead of sending you off to a browser login, you POST your credentials to an MFA validation endpoint and pass the resulting session token into the authorization request.

First, fetch settings.json and grab those four values. Second, generate a PKCE pair -- a random verifier, and a challenge that's the base64url-encoded SHA256 of it with the padding stripped. Third, POST your username and password as form fields to /aio/api/v1/mfa/validate/full on the SSO host. What comes back is a session token, not the bearer token you're after. Fourth, request /as/authorization.oauth2 with the client id, redirect URI, a response type of code, a scope of "profile openid", a random state, the challenge and its method, and that session token. Don't follow the redirect -- the authorization code is sitting in the query string of the Location header. Fifth, POST that code back to /as/token.oauth2 along with the verifier, and you finally get a real bearer token.

Because that third step is a plain username and password POST, the account you use can't have MFA enabled. Please don't strip MFA off your main account to make this work. Create a separate account with an email alias, leave MFA off, and invite it to your site from your primary account. View-only is enough for every endpoint below -- I tested it -- and it keeps the blast radius small if that account ever gets away from you.

The API

Everything is a GET against the API base with two headers, an Authorization bearer token and the version header above. Collection responses wrap their list in an "elements" key.

The endpoints I found useful:

The client records are richer than I expected. Along with the friendly name from the dashboard, which is the whole reason I'm here, you get the MAC, IP, hostname, detected OS, signal strength in dBm, network name, VLAN, traffic counters and a pile of health metrics.

One gotcha: each record has a kind field, but it's the resource type, not the client type. It says "clientSummary" for everything. Use wirelessNetworkId for that instead. Also, in my testing the wired clients all came back with an empty IP address.

The script

Python, with requests as the only dependency; the PKCE bits are all standard library. It reads INSTANT_ON_USER and INSTANT_ON_PASSWORD from the environment or from a .env file in the working directory, and prints an aligned table. Pass --json if you'd rather have JSON.

#!/usr/bin/env python3
# Dump MAC -> friendly-name pairings from the unofficial Aruba Instant On cloud API.
# The API is undocumented and unsupported by HPE; endpoints reverse-engineered by the
# community (https://mspp.io/documenting-aruba-instant-on-sites-aruba-instant-on-api/).
# Requires a portal account WITHOUT MFA (the scripted login can't answer a challenge).

import argparse
import base64
import hashlib
import json
import os
import secrets
import sys
from urllib.parse import parse_qs, urlparse

import requests

# portal.arubainstanton.com now 308-redirects here (HPE rebrand); settings.json
# self-describes the SSO host, OAuth client id, redirect URI, and API base
PORTAL_BASE = 'https://portal.instant-on.hpe.com'
ION_API_VERSION = '7'

# Load .env file
try:
  with open('.env', 'r') as f:
    for line in f:
      if line.strip() and not line.startswith('#') and '=' in line:
        key, value = line.strip().split('=', 1)
        os.environ[key] = value
except FileNotFoundError:
  pass


def get_bearer_token(settings, username, password):
  sso_base = settings['ssoFqdn']
  redirect_uri = settings['ssoRedirectUrl']

  # PKCE: challenge is base64url(sha256(verifier)) without padding
  code_verifier = secrets.token_urlsafe(48)
  code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b'=').decode()

  r = requests.post(f'{sso_base}/aio/api/v1/mfa/validate/full', data={'username': username, 'password': password})
  if r.status_code != 200:
    sys.exit(f'login failed ({r.status_code}): check credentials, and that the account has MFA disabled')
  session_token = r.json()['access_token']

  # the authorization code comes back in the redirect Location, so don't follow it
  r = requests.get(f'{sso_base}/as/authorization.oauth2',
                   params={
                     'client_id': settings['ssoClientIdAuthZ'],
                     'redirect_uri': redirect_uri,
                     'response_type': 'code',
                     'scope': 'profile openid',
                     'state': secrets.token_urlsafe(32),
                     'code_challenge_method': 'S256',
                     'code_challenge': code_challenge,
                     'sessionToken': session_token,
                   },
                   allow_redirects=False)
  location = r.headers.get('Location', '')
  query = parse_qs(urlparse(location).query)
  if 'code' not in query:
    sys.exit(f'authorization failed: no code in redirect ({location or r.status_code})')

  r = requests.post(f'{sso_base}/as/token.oauth2',
                    data={
                      'client_id': settings['ssoClientIdAuthZ'],
                      'redirect_uri': redirect_uri,
                      'code': query['code'][0],
                      'code_verifier': code_verifier,
                      'grant_type': 'authorization_code',
                    })
  r.raise_for_status()
  return r.json()['access_token']


def api_get(settings, token, path):
  r = requests.get(f'{settings["restApiUrl"]}/api/{path}',
                   headers={
                     'Authorization': f'Bearer {token}',
                     'x-ion-api-version': ION_API_VERSION
                   })
  r.raise_for_status()
  data = r.json()
  # collection responses wrap the list in "elements"
  return data.get('elements', data) if isinstance(data, dict) else data


def first_key(record, *candidates):
  # field names vary slightly across API versions, so probe a few
  for key in candidates:
    if record.get(key):
      return record[key]
  return ''


def collect_clients(settings, token, site_id, site_name):
  # clientSummary covers wired and wireless alike (the old wiredClientSummary endpoint is gone)
  rows = []
  for client in api_get(settings, token, f'sites/{site_id}/clientSummary'):
    rows.append({
      'site': site_name,
      'name': first_key(client, 'name', 'hostName'),
      'mac': first_key(client, 'macAddress', 'mac').lower(),
      'ip': first_key(client, 'ipAddress', 'ip'),
      'kind': 'wireless' if client.get('wirelessNetworkId') else 'wired',
    })
  return rows


def main():
  parser = argparse.ArgumentParser(description='List MAC -> friendly-name pairings from Aruba Instant On')
  parser.add_argument('--json', action='store_true', help='output JSON instead of a table')
  args = parser.parse_args()

  try:
    username = os.environ['INSTANT_ON_USER']
    password = os.environ['INSTANT_ON_PASSWORD']
  except KeyError as e:
    sys.exit(f'missing {e.args[0]} -- add it to .env or the environment')

  settings = requests.get(f'{PORTAL_BASE}/settings.json').json()
  token = get_bearer_token(settings, username, password)

  rows = []
  for site in api_get(settings, token, 'sites/'):
    rows.extend(collect_clients(settings, token, site['id'], first_key(site, 'name', 'siteName')))
  rows.sort(key=lambda r: (r['site'], r['name'].lower()))

  if args.json:
    print(json.dumps(rows, indent=2))
    return

  widths = {col: max([len(col)] + [len(r[col]) for r in rows]) for col in ('mac', 'name', 'ip', 'kind', 'site')}
  for r in [{col: col.upper() for col in widths}] + rows:
    print('  '.join(r[col].ljust(widths[col]) for col in ('mac', 'name', 'ip', 'kind', 'site')))


if __name__ == '__main__':
  main()

Output looks like this:

MAC                NAME         IP            KIND      SITE
aa:bb:cc:dd:ee:01  apple-tv     192.168.1.20  wireless  Home
aa:bb:cc:dd:ee:02  thermostat   192.168.1.31  wireless  Home
aa:bb:cc:dd:ee:03  nas          192.168.1.10  wired     Home

Getting set up is three steps. Create a fresh Instant On account at portal.instant-on.hpe.com using an email alias, verify it, and leave MFA off. Invite it to your site from your main account with a view-only role, then accept the invite. Finally, export the two environment variables (or drop them in a .env next to the script) and run it.

Keep in mind that's still a real login to your network's dashboard, view-only or not. Mine lives in a .env file that stays well out of Git.

That's it. Credit to Luke Whitelock and the others who did the original digging -- hopefully this saves someone the hour I spent working out what the rebrand broke.

Disclaimer: Claude Fable co-authored this post and provided the Python script.