← Blog
Analytics & Monitoring·12 July 2026·7 min read

aarm: Find Expiring Entra App Secrets — from the CLI, with No Azure Infrastructure

Client secrets on Entra App Registrations expire silently — no warning, no tenant-wide view. aarm is a lean CLI that lists every secret in your tenant, scores the risk and emits JSON for automation. No desktop app, no Azure infrastructure required.

A client secret on an App Registration is a ticking clock. It expires — quietly, with no warning, and Microsoft never sends a reminder. Worse: there is no built-in view that shows you all expiring secrets across your tenant. You usually find out when authentication breaks in production — typically on a Friday evening.

The problem: secrets that expire silently

Every App Registration in Microsoft Entra ID can carry several passwordCredentials (client secrets), each with its own expiry date. In a tenant that has grown over the years, that quickly becomes hundreds of secrets across dozens of apps. There is no automatic renewal, no notification, and no central list. Keeping track means clicking through the portal app by app — or writing your own Graph script.

That is exactly the gap aarm — the Azure App Registration Monitor CLI — fills.

What aarm does

aarm authenticates against your tenant, queries Microsoft Graph for every App Registration and its secrets, and calculates the expiry status and a risk level for each one. You get the result as a readable table or as stable JSON for automation.

  • Tenant-wide overview instead of clicking app by app
  • Risk scoring per secret (Info → Critical, based on time remaining)
  • Preflight checks that tell you which permissions are actually available
  • Stable JSON for CI/CD, scheduled jobs and scripts

aarm is part of the AARM toolchain and uses the same engine as the AARM desktop app (the npm library @brunsforge/azure-app-registration-monitor). The point that carries this article: you need neither the desktop app nor any Azure infrastructure. The CLI on its own reads your tenant’s secret information — on your laptop, in a pipeline, in a cron job.

Install

The fastest route is npm (Node.js ≥ 18 required):

npm install -g @brunsforge/aarm
aarm --version

If you want to work straight from source, or the package is not reachable in your environment, clone the monorepo from GitHub and build core + CLI:

git clone https://github.com/brunsforge/AzureAppRegistrationSecretMonitor.git
cd AzureAppRegistrationSecretMonitor

# Install workspace dependencies (core + CLI)
npm install

# Build the core library first, then the CLI
npm run build --workspace packages/core
npm run build --workspace packages/cli

# Put aarm on your PATH
cd packages/cli
npm link

aarm --version

For quick iteration without a build step, npx tsx src/index.ts <command> runs straight from packages/cli.

One-time setup: App Registration + tenant

aarm needs one App Registration of its own per tenant — that is aarm’s identity for calling Graph. It is separate from all the App Registrations aarm reads. Minimum permission: Application.Read.All (Microsoft Graph) with admin consent granted.

The fastest path for a first test is device-code mode with a public client — you sign in interactively in the browser, with no secret to manage:

  1. Azure Portal → Entra ID → App registrations → New registration, name e.g. aarm-test, single tenant, no redirect URI
  2. API permissions → Microsoft Graph → Delegated → Application.Read.All
  3. Grant admin consent (requires a Global Administrator)
  4. Authentication → Allow public client flows: Yes
  5. Copy the Application (client) ID

For the delegated modes (device-code, interactive-browser, azure-cli) your user also needs the Entra role Cloud Application Administrator or Application Administrator — otherwise you only see apps you personally own.

Then register the tenant in aarm:

aarm tenants add \
  --tenant-id    "<entra-tenant-id-guid>" \
  --display-name "Contoso PROD" \
  --auth-mode    device-code \
  --client-id    "<app-registration-client-id>"

For unattended runs (CI/CD) use --auth-mode client-secret with a daemon app and Application permissions instead; aarm prompts for the secret and stores it in the OS credential store (Windows Credential Manager / macOS Keychain / libsecret) — never in a JSON file.

Check first, then read: preflight

Before listing secrets, confirm that auth and permissions are in place:

aarm --tenant "Contoso PROD" preflight run
Preflight — xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Environment : prod
Checked at  : 2026-05-01T12:00:00.000Z

Authentication  : OK
Graph reachable : OK

Capabilities
┌────────────────────────────────────┬──────────────────────────────┐
│ Capability                         │ Status                       │
├────────────────────────────────────┼──────────────────────────────┤
│ canReadApplications                │ [✓] Available                │
│ canReadApplicationSecrets          │ [✓] Available                │
│ canReadOwners                      │ [ ] Unavailable              │
└────────────────────────────────────┴──────────────────────────────┘

Missing permissions
  ✗ Delegated permission missing: Directory.Read.All (admin consent required)

If something is missing, aarm --tenant "Contoso PROD" preflight explain tells you in plain language which permission is missing and where in the portal to grant it — mode-aware, matched to your auth mode.

Read the secrets

Now the core — every secret in the tenant, colour-coded by risk:

# All secrets
aarm --tenant "Contoso PROD" secrets list

# Only those expiring in the next 90 days
aarm --tenant "Contoso PROD" secrets expiring --days 90

# Already expired
aarm --tenant "Contoso PROD" secrets expired

The table makes the risk obvious at a glance:

┌──────────┬──────────────────┬─────────────┬────────────┬──────┬─────────────┐
│ Risk     │ App              │ Secret      │ Expires    │ Days │ Status      │
├──────────┼──────────────────┼─────────────┼────────────┼──────┼─────────────┤
│ CRITICAL │ Export Worker    │ prod-secret │ expired    │ -4   │ Expired     │
│ HIGH     │ CRM Connector    │ crm-prod    │ 21/05/2026 │ 21   │ ExpiringSoon│
│ MEDIUM   │ Teams Bot        │ bot-secret  │ 10/07/2026 │ 71   │ Valid       │
│ INFO     │ Test Tool        │ dev-secret  │ 01/11/2026 │ 185  │ Valid       │
└──────────┴──────────────────┴─────────────┴────────────┴──────┴─────────────┘
4 secret(s)

For the bird’s-eye view there is aarm --tenant "Contoso PROD" apps list — the same risk logic, but aggregated per app (secret count, expired, expiring).

JSON for automation — the real lever

Tables for humans, --output json for machines. Every JSON output follows the same stable envelope: success, metadata, data, warnings, errors.

aarm --tenant "Contoso PROD" secrets expiring --days 30 --output json
{
  "success": true,
  "metadata": {
    "tenantId": "<tenant-id>",
    "environmentName": "default",
    "generatedAt": "2026-05-01T12:00:00.000Z",
    "toolVersion": "0.1.0"
  },
  "data": [
    {
      "appDisplayName": "CRM Connector",
      "keyId": "…",
      "displayName": "crm-prod",
      "startDateTime": "2025-01-15T00:00:00Z",
      "endDateTime": "2026-05-21T00:00:00Z",
      "daysUntilExpiry": 21,
      "status": "ExpiringSoon",
      "riskLevel": "High"
    }
  ],
  "warnings": [],
  "errors": []
}

Because the format is stable, you can pipe the output straight into other tools. A pipeline gate that turns red the moment a secret is expired:

# GitHub Actions
- name: Check for expired secrets
  run: |
    aarm --tenant "Contoso PROD" --output json secrets expired \
      | jq '.data | length' \
      | xargs -I{} test {} -eq 0
  env:
    AARM_CONFIG_DIR: ${{ runner.temp }}/aarm-config

Or filter precisely with jq, for example before a rotation:

aarm --tenant "Contoso PROD" secrets expiring --days 14 --output json \
  | jq '.data[] | {app: .appDisplayName, keyId: .keyId, days: .daysUntilExpiry}'

Reports you can send

For tickets and emails, aarm report produces ready-made output in table, json, markdown or csv:

# Expiring secrets as Markdown (paste straight into a ticket)
aarm --tenant "Contoso PROD" report expiring --days 30 --output markdown

# Whole-tenant summary: apps, secrets, risk distribution
aarm --tenant "Contoso PROD" report tenant-summary

# Rotation checklist for everything expiring soon
aarm --tenant "Contoso PROD" report rotation-guide --days 14 --output markdown

Where the data lives

aarm keeps its profiles under ~/.aarm/tenants.json (non-sensitive) plus a history/ folder per tenant with the last scans. Secrets and passwords never land in these files; they go into the OS credential store. Handy: the desktop app uses the same store and the same config format, so a tenant configured via aarm tenants add is immediately available there. With --config-dir (or AARM_CONFIG_DIR) you can isolate a dedicated configuration in CI/CD.

Where the benefit is

  • Visibility across the whole tenant instead of app-by-app portal clicking
  • Risk already sorted — you know what to tackle first
  • Automation-ready through the stable JSON envelope and exit codes (0 ok, 2 auth, 3 missing permission, 4 configuration …)
  • Safe credentials in the OS store, never in plaintext
  • No overhead: no desktop app, no Azure Function, no hosted infrastructure — just Node and one App Registration

Get started

npm install -g @brunsforge/aarm
aarm tenants add                       # interactive
aarm --tenant "Contoso PROD" preflight run
aarm --tenant "Contoso PROD" secrets expiring --months 3

The source and full docs (every auth mode, every portal setting) are open on GitHub: github.com/brunsforge/AzureAppRegistrationSecretMonitor. The npm package is at @brunsforge/aarm.

See also