Skip to content

Security Cloud Workflows

Neil Martin edited this page Sep 11, 2026 · 6 revisions

Security Cloud Workflows

Copy-paste recipes for Jamf Security Cloud administration. For command details, see Jamf Security Cloud Commands. For shared patterns (scaffold, --set, dry-run, destructive confirmation), see CLI Patterns.

Two halves, two credentials. The Radar APIs (risk, device-lifecycle, stream, status, verification, well-known, jwks) take the three scoped application pairs jamf-cli security setup writes. The Jamf Platform gateway serves everything else (dns-*, ztna-*, content-categories, device-groups, uem-*, enrollment-activation-profiles), and those take a platform gateway credential, from jamf-cli platform setup. Each recipe below says which it needs.

Identifying a command's API

# Every command, with the API that serves it
jamf-cli commands -o json | jq -r '.[] | select(.product=="security") | .command + "  " + (.api // "")'

# The gateway-served half only
jamf-cli commands -o json | jq -r '.[] | select(.product=="security" and .api=="platform-gateway") | .command'

jamf-cli security --help and jamf-cli security <TAB> carry the same fact per command, as (Security Cloud · platform gateway) or (Security Cloud · Radar API). Seven resources carry the Radar marker: risk, device-lifecycle, stream, status, verification, jwks, well-known. Everything else is gateway-served.

First-Time Setup

Both setup commands merge into the profile they name, so run both against one name for a profile that reaches everything.

# Radar half: interactive wizard, one application ID/secret pair per API,
# leave an application ID blank to skip that API
jamf-cli security setup --profile-name jsc

# Platform gateway half: region, client credentials, and the scope the
# integration was created at (environment is the level to prefer)
jamf-cli platform setup --profile-name jsc

jamf-cli -p jsc security risk list        # Radar
jamf-cli -p jsc security dns-zones list   # gateway

platform setup validates the credentials with a token exchange and probes the scope ID you typed, since a mis-pasted environment ID saves without error and then refuses every command. It closes by reporting which Platform API resource groups your credential's level declares. Permissions are granted per operation, so a 403 is what names the one you are missing.

Non-interactive (CI):

# Radar half
export JAMFSECURITY_RISK_CLIENT_ID="..."
export JAMFSECURITY_RISK_CLIENT_SECRET="..."
export JAMFSECURITY_SSE_CLIENT_ID="..."
export JAMFSECURITY_SSE_CLIENT_SECRET="..."
jamf-cli security risk list

# Gateway half: the ordinary platform variables. The gateway HOST is what
# selects platform auth; there is no auth-method environment variable.
export JAMF_URL="https://eu.api.jamfcloud.com"
export JAMF_CLIENT_ID="..."
export JAMF_CLIENT_SECRET="..."
export JAMF_ENVIRONMENT_ID="<platform-environment-id>"   # or JAMF_TENANT_ID, never both
jamf-cli security dns-zones list

Set both halves of a Radar pair or neither: an ID from the environment with the secret coming from a profile is refused. JAMF_ENVIRONMENT_ID and JAMF_TENANT_ID are mutually exclusive. See Configuration & Profiles#Jamf Security Cloud.

Scope level for the gateway half. Prefer environment scope: one EU environment credential reaches every gateway-served Security Cloud command, with content categories, DNS, ZTNA, UEM Connect and device groups all answering 200. Tenant scope is the legacy level, a tenant credential lacking the Security Cloud grants answers 403 BAD_PERMISSIONS, and device-groups hits a gateway fault under it. See Managing Device Groups.

Device Risk Reporting

Radar half: Risk credentials.

# List device risk status (JSON by default)
jamf-cli security risk list

# Table view
jamf-cli security risk list -o table

# Export the full risk report to CSV
jamf-cli security risk list -o csv --out-file device-risk.csv

# Look up one device by guid
jamf-cli security risk list --guid <device-guid>

# Look up one device by externalId
jamf-cli security risk list --external-id <external-id>

Overriding Device Risk

Radar half: Risk credentials.

# 1. Generate the request-body template
jamf-cli security risk override --scaffold > override.json

# 2. Edit override.json: set deviceIds, risk, and source
#    (deviceIds has no element schema upstream, so the scaffold renders it as [])

# 3. Apply the override
jamf-cli security risk override --from-file override.json

Purging Devices (Device Lifecycle)

Radar half: Device Lifecycle credentials. The most destructive command in the CLI.

Purge requires --yes or an interactive confirmation. The --dry-run preview prints before any confirmation prompt, so previewing does not need --yes.

# 1. Build the purge body from the scaffold
jamf-cli security device-lifecycle purge --scaffold > purge.json

# 2. Edit purge.json: fill externalIds and/or guids

# 3. Preview (method, resolved path and body to stderr; nothing is sent)
jamf-cli security dl purge --from-file purge.json --dry-run

# 4. Execute
jamf-cli security dl purge --from-file purge.json --yes

jamf-cli fills the customer ID in from the Device Lifecycle credentials at request time. A body that scopes nothing is refused before the preview.

Configuring the SSE Stream

Radar half: Shared Signals & Events credentials.

# 1. Inspect the current stream configuration
jamf-cli security stream get

# 2. Scaffold, edit (aud, delivery, events_requested, format...), and apply
jamf-cli security stream update --scaffold > stream.json
# ... edit stream.json ...
jamf-cli security stream update --from-file stream.json

# 3. Check the stream status
jamf-cli security status get

# 4. Trigger a verification event to confirm end-to-end delivery
jamf-cli security verification trigger

# 5. Inspect the transmitter's discovery document and signing keys
jamf-cli security well-known get
jamf-cli security jwks get

Pausing / resuming the stream

# Update the stream status (status/reason values per the SSE framework)
jamf-cli security status update --set status=<status> --set reason="maintenance window"

Tearing down the stream

# Delete the stream configuration (destructive)
jamf-cli security stream delete --yes

Standing Up a DNS Zone

Gateway half: a platform gateway credential.

# 1. Read the current zones
jamf-cli security dns-zones list -o table

# 2. Scaffold the zone body
jamf-cli security dns-zones create --scaffold > zone.json
{
  "domains": ["corp.example.com"],
  "name": "corp-internal",
  "nameServers": [
    { "gatewayId": "gw-eu-01", "ip": "198.51.100.53" }
  ]
}
# 3. Preview, then create
jamf-cli security dns-zones create --from-file zone.json --dry-run
jamf-cli security dns-zones create --from-file zone.json

# 4. Read it back by ID, or by the name you gave it
jamf-cli security dns-zones get --name corp-internal

# 5. Adjust later with a merge patch: only what you send changes
jamf-cli security dns-zones patch --name corp-internal --set name=corp-internal-eu

Two rules the spec does not state: nameServers[].ip must be a public address (10.0.0.53 is refused 422 NAMESERVER_IP_RESTRICTED, 198.51.100.53 is accepted), and gatewayId must name a gateway that exists (an unknown one is 422 GATEWAY_NOT_FOUND). domains takes 1–100 entries and nameServers 1–20; list does not paginate and --sort accepts only name:asc / name:desc.

The rest of the DNS surface

# Tenant search domain (a singleton). get renders {} and exits 0 when none is set.
jamf-cli security dns-search-domains get
jamf-cli security dns-search-domains update --set suffix=corp.example.com

# Custom hostname mappings: a whole-list replace whose body is a top-level
# JSON array, so --set cannot express it.
# NOTE: get returns the collection envelope {results, totalCount} while update
# wants the bare array, so unwrap .results on the way through.
jamf-cli security dns-custom-hostname-mappings get | jq '.results' > mappings.json
# ... edit mappings.json: it replaces the entire set, max 500 entries ...
jamf-cli security dns-custom-hostname-mappings update --from-file mappings.json

Publishing a ZTNA App

Gateway half: a platform gateway credential.

# 1. Pick a category. categoryName is REQUIRED and the scaffold renders it
#    empty, which is the one value the server rejects.
jamf-cli security content-categories list -o table

# 2. Optional: start from a predefined SaaS definition instead of hostnames
jamf-cli security ztna-predefined-apps list -o table

# 3. Scaffold and edit
jamf-cli security ztna-apps create --scaffold > app.json

A minimal custom app, routed through a gateway:

{
  "name": "Internal CRM",
  "categoryName": "Uncategorized",
  "hostnames": ["crm.example.com"],
  "routing": { "type": "CUSTOM", "gatewayId": "a1b2" },
  "assignments": { "inclusions": { "allUsers": true } }
}
# 4. Create. This returns {id, href}, NOT the created app
jamf-cli security ztna-apps create --from-file app.json

# 5. So read it back for the fields
APP_ID=$(jamf-cli security ztna-apps create --from-file app.json --field id)
jamf-cli security ztna-apps get "$APP_ID"

# 6. Tighten it later. patch is all-optional
jamf-cli security ztna-apps patch "$APP_ID" \
  --set security.riskControls.enabled=true \
  --set security.riskControls.levelThreshold=HIGH

Traps, wire-verified and invisible in the response:

  • categoryName: "" is refused (400 [INVALID_FIELD] categoryName: must not be blank). A category that does not exist is 409 MISSING_CATEGORY_NAME.
  • Setting predefinedAppId inherits name and hostnames from the template, and the server drops a name you send: 201, and the app reads back name: null. Those apps are unreachable by --name, so address them by ID. Reusing a template another app already holds is 409 CONFLICT.
  • The server accepts an unknown key under security and drops it. --set security.riskControls.enabld=true answers 201 and does nothing.
  • --help lists the allowed enum values, which the scaffold renders as "".

Gateways behind the app

# Jamf-managed shared gateways available to this tenant (id and name only)
jamf-cli security ztna-shared-gateways list -o table

# Dedicated gateways
jamf-cli security ztna-gateways list -o table
jamf-cli security ztna-gateways create --scaffold > gw.json
jamf-cli security ztna-gateways create --from-file gw.json

# A partial IPSec change, deep-merged server-side, so send only what changes.
# Note: patch prints NOTHING on success (204, no body).
jamf-cli security ztna-gateways patch <gatewayId> --set ipsec.esp.lifetimeInSec=14400
jamf-cli security ztna-gateways get <gatewayId>

# Group dedicated gateways for failover / nearest routing
jamf-cli security ztna-grouped-gateways create --from-file group.json
  • ipsec.left.subnets accepts one element; right.subnets accepts several. Two left subnets earn 400 [INVALID_FIELD] field=ipsec "IPSec configuration is not valid.", naming neither the field nor the cardinality. Left subnets must be private.
  • ipsec.right.vendor is case-sensitive: "cisco" for "Cisco" is a 400 with no field and a generic "Request body is missing or malformed."
  • contact is required (400 … contact: must not be null).
  • ztna-grouped-gateways create requires recoveryDelayInSec, an enum of 300, 1800, 3600, 10800, 28800. The server rejects 0 under every routing strategy.
  • All three ZTNA creates return {id, href}.

Connecting a Jamf Pro Instance (UEM Connect)

Gateway half: a platform gateway credential.

A tenant holds at most one connector, whatever its vendor. Check before creating: a second create answers 409 CONNECTOR_CONFIG_ALREADY_EXISTS.

# 1. Is there one already?
jamf-cli security uem-connectors list -o table

# 2. Scaffold. The body is a discriminated union over ten vendor shapes keyed
#    on "vendor"; --scaffold renders the Jamf Pro branch, the only wire-verified
#    one. --help's "Allowed values" for authStrategy spans EVERY variant and is
#    wider than the shape you are looking at.
jamf-cli security uem-connectors create --scaffold > connector.json

Edit it down to one credential shape. The scaffold's deviceSyncAuth carries the union of the OAuth and BASIC pairs, so delete the half your authStrategy does not use:

{
  "vendor": "JAMF_PRO",
  "authStrategy": "JAMF_PRO_OAUTH",
  "url": "https://yourcompany.jamfcloud.com",
  "isoCountry": "US",
  "deviceSyncAuth": {
    "clientId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
    "clientSecret": "…"
  }
}
# 3. Create. Keep the secret in the file, never in --set, which puts it in
#    shell history and ps output.
jamf-cli security uem-connectors create --from-file connector.json

# The connector read model carries no name, so --name cannot resolve one.
# A tenant holds at most one connector, so the id off the list is the id.
CONFIG_ID=$(jamf-cli security uem-connectors list --field id)

# 4. Turn synchronization on (idempotent)
jamf-cli security uem-connector-enablement enable "$CONFIG_ID" --set enabled=true

# 5. Sync settings. `update` is a FULL replacement, so start from the current
#    values. `get` returns the whole connector config, a wider shape than
#    `update` accepts, so read the values off it and build the body from the
#    scaffold; piping one into the other fails.
jamf-cli security uem-sync-settings get "$CONFIG_ID"
jamf-cli security uem-sync-settings update --scaffold > settings.json
# ... edit settings.json: the ten fields update accepts, at their current
#     values, plus whatever you are changing ...
jamf-cli security uem-sync-settings update "$CONFIG_ID" --from-file settings.json

# 6. Kick off a sync (202 Accepted, it runs in the background) and watch it
jamf-cli security uem-sync trigger "$CONFIG_ID"
jamf-cli security uem-sync list "$CONFIG_ID" -o table

# 7. Cancel a run in flight (best-effort, it may finish first)
jamf-cli security uem-sync cancel "$CONFIG_ID"

Before running this in a loop:

  • authStrategy: M2M takes a tenantId and no credentials: Security Cloud self-provisions what it needs on the named Jamf Pro, and url is then ignored. The Jamf Pro API role and integration it provisions survive the connector's DELETE, so repeated create/delete cycles leak an enabled integration per create onto the target instance, with nothing in the connector response pointing at one. List them with jamf-cli pro api-integrations list on an instance (oauth2) profile; a gateway profile refuses that command.
  • Error ladder on create: no authStrategy → 500 INTERNAL_ERROR; authStrategy with no deviceSyncAuth → 422 VALIDATION_FAILED ": invalid auth configuration for Jamf PRO"; a complete body when a connector already exists → 409 CONNECTOR_CONFIG_ALREADY_EXISTS.
  • refreshRateMinutes enforces its enum: 60, 120, 240, 480, 720, 1440. 360, 59, 1 and 0 are all 422 VALIDATION_FAILED; the message leaks the accepted set and leaves field null.
  • The server discards deviceUnmanagedThreshold for JAMF_PRO connectors. It answers 204 and reads back 0 whatever you sent, while --help lists 0, 1, 3, 5, 7, 14 as if it applied. A grace period set on a Jamf Pro connector does not exist.
  • The body's vendor must match the connector's stored vendor: a mismatch is 422 VENDOR_MISMATCH.
  • A connector read returns clientId and username and no secret, so a JAMF_PRO_OAUTH connector cannot be re-created from its own output. An M2M one can.

Issuing an Enrollment Activation Profile

Gateway half: a platform gateway credential. Alias: eap.

# 1. List existing profiles. --origin is required and PUBLIC_API is its only legal value.
jamf-cli security eap list --origin PUBLIC_API

# 2. Optionally scope the profile to a device group
jamf-cli security device-groups list -o table

# 3. Scaffold and edit
jamf-cli security eap create --scaffold > profile.json
{
  "name": "iOS BYOD onboarding",
  "origin": "PUBLIC_API",
  "platforms": ["iOS"],
  "groupId": "grp-abc123",
  "capabilities": {
    "dataPolicy": true,
    "networkSecurity": true,
    "vulnerabilityManagement": true,
    "note": "BYOD pilot"
  }
}
# 4. Create. The response is {"code": "…"} and the code is the only handle
CODE=$(jamf-cli security eap create --from-file profile.json --field code)

# 5. Deploy the Jamf Security configuration profiles to the UEM for that code
#    (the last step of automated UEM Connect onboarding)
jamf-cli security uem-activation-profiles deploy-to-uem "$CODE" \
  --set platform=SUPERVISED_IOS --set uem=JAMF

# 6. Take it out of service without deleting it (204, prints nothing)
jamf-cli security eap pause "$CODE"
jamf-cli security eap resume "$CODE"

# 7. Delete a batch (destructive; codes is an array)
jamf-cli security eap delete-multiple --set codes="[\"$CODE\"]" --yes

Caveats specific to this resource:

  • The read model is a code and nothing else. get returns {"code": "…"} and omits the name, platforms, capabilities and state you sent, so a create's input cannot be read back, and pause / resume have no observable effect on any GET.
  • delete-multiple is a soft delete the read surface does not reflect. After it answers 204, get <code> still answers 200 and list still returns the code; pause on a deleted code is 409 STATE_CONFLICT ("… is already deleted."). It also skips a code it cannot delete (non-existent, restricted by origin, or in use by an unfinished device transition) and still answers 204, so a 204 is not evidence anything was deleted.
  • capabilities.networkSecurity and capabilities.vulnerabilityManagement must be both on or both off: 400 [INVALID_FIELD] capabilities: networkSecurity and vulnerabilityManagement must both be enabled or both disabled.
  • groupId is not validated. A mistyped group answers 201 with the profile scoped to nothing.
  • platforms accepts iOS and MAC only, and the server applies the 1–2 limit after de-duplication, so ["iOS","MAC","iOS"] is accepted.

Managing Device Groups

Gateway half: a platform gateway credential, and environment-scoped for this resource (see the fault below).

jamf-cli security device-groups list -o table
jamf-cli security device-groups create --set name="Engineering Team"
jamf-cli security device-groups get --name "Engineering Team"
jamf-cli security device-groups update <groupId> --set name="Engineering"
jamf-cli security device-groups delete <groupId> --yes

# Idempotent create-or-update, keyed on the name in the body
jamf-cli security device-groups apply --set name="Engineering Team" --yes

A group carries a name and nothing else, so update changes the name and prints nothing on success: it sends PUT /securitycloud/v2/groups/{groupId}, which is a 204 with no body. Read the change back with list. The default group cannot be deleted.

The implicit "Default Group" has no ID. list returns it as {"name": "Default Group"} with no id, since it is not a stored group; a group you create carries its id and resolves by --name. apply on that one name refuses instead of creating a second group with it:

found 1 item(s) named "Default Group" in /securitycloud/v2/groups, but the list
returns no ID for them; identify the item by ID instead

Under tenant scope, device-groups list answers a gateway templating error:

[URI_NOT_TEMPLATED] Failed to (fully) template URI to the downstream service.
See logs from the request templater.

on GET /securitycloud/v2/groups, exit code 1, re-confirmed 2026-09-11 against the same tenant whose environment-scoped credential returns the group list from that same v2 path. The path templates under X-Environment-Id and fails under X-Tenant-Id. Use an environment-scoped credential; this is an upstream gateway fault.

Multi-Instance Operations

Run the same read across several profiles with multi.

# Risk report across all security profiles (Radar half)
jamf-cli multi --filter 'security-*' -- security risk list -o table

# DNS zones across every gateway profile (gateway half)
jamf-cli multi --filter 'jsc-*' -- security dns-zones list -o table

Scripting Tips

  • Output defaults to JSON, so pipe to jq or use --field to extract one value. An empty list prints [], never null. See Output Formats.
  • Writes take --from-file (JSON or YAML), piped stdin, and/or --set key=value. Leave --from-file off and the body is read from stdin. --set overlays onto whichever supplied the body, decodes JSON literals (--set codes='["a","b"]'), and errors when a path segment already holds a non-object. A piped body over 10MB is refused, so use --from-file for one that size.
  • apply is create-or-update keyed on the name in the body, on dns-zones, ztna-apps, ztna-gateways, ztna-grouped-gateways and device-groups. The exists check and the create are separate requests and no spec here declares a name-uniqueness 409, so two jobs racing on the same absent name can both create one: serialise apply per resource in a pipeline that can retry. A failed lookup (auth error, 5xx) aborts.
  • --select, --compact, --out-file, --quiet and --no-hints work on every command in the namespace.
  • -n, --dry-run prints the method, resolved path and body to stderr and sends nothing. The preview comes before the confirmation prompt, so -n works on destructive commands without --yes. Never pair -n --yes in a script: the day -n falls off the command line, the confirmation is already suppressed.
  • Destructive commands (device-lifecycle purge, stream delete, every gateway delete, eap delete-multiple) refuse to run under --no-input without --yes. See CLI Patterns#Destructive Operations.
  • Each Radar API is independent, so a recipe touching only Risk works with SSE credentials never configured. A command for an unconfigured Radar API names that API and exits 1 (list: Risk API credentials are not configured; run 'jamf-cli security setup'). A gateway-served command with no platform credentials fails with this command requires platform gateway auth and a config add-profile line.
  • create on ZTNA apps, gateways and grouped gateways returns {id, href}, so capture --field id and follow with a get when the script needs the object.
  • A gateway-half 403 names the permission in Jamf Account's own words and exits 5, for example grant the Jamf Platform API integration these permissions in Jamf Account: Secure enterprise access > Content categories: Read (content-categories:read), with the permissions-map URL, since you search the picker by name. DNS zones report Zero-Trust Network Access (ZTNA): Read (ztna:read): they need the ztna grant and have no dns: one. Exit codes, including 8 (refused by policy) and 5 (a permission denial), are in Error Handling & Exit Codes.

See Jamf Security Cloud Commands for the full reference, Platform API GA Migration for the gateway (regions, scope levels, the GA base URL), Jamf Platform Commands for the rest of the platform namespace, and Setup Guide for first-run walkthroughs.

jamf-cli Wiki


Products

  • Jamf Pro: jamf-cli pro
  • Jamf Platform API: jamf-cli pro (blueprints, benchmarks, DDM reports)
  • Jamf Platform: jamf-cli platform (AI Governance, Jamf Account, audit)
  • Jamf Protect: jamf-cli protect
  • Jamf School: jamf-cli school
  • Jamf Security Cloud: jamf-cli security

Clone this wiki locally