Atlassian Cloud Hardening Guide
Platform-wide security hardening for Atlassian Cloud — the Common Controls hub (organization authentication, Atlassian Guard, Marketplace app governance, data security policies, org audit logging) shared by the Jira Cloud and Bitbucket product guides.
Overview
Atlassian serves 300,000+ customers with the Atlassian Marketplace hosting 6,000+ apps. OAuth 2.0, Connect (JWT), and Forge app frameworks create multiple attack vectors. Critical RCE vulnerabilities (CVE-2023-22515, CVSS 10.0; CVE-2022-26134, CVSS 9.8) demonstrated server-side risks. Cloud instances face OAuth token and AppLinks impersonation attacks from compromised Marketplace apps.
Intended Audience
- Security engineers managing Atlassian products
- IT administrators configuring Jira and Confluence
- GRC professionals assessing collaboration tool security
- Third-party risk managers evaluating Marketplace apps
How to Use This Guide
- L1 (Crawl): Essential controls for all organizations
- L2 (Walk): Enhanced controls for security-sensitive environments
- L3 (Run): Strictest controls for regulated industries
Scope
This guide is the Common Controls hub for the Atlassian platform. It covers the organization-wide Atlassian Cloud (and, where noted, Data Center) configurations that apply across every Atlassian product: authentication and Atlassian Guard, Marketplace app governance, API and integration security, data residency and data security policies, and organization audit logging. Product-specific hardening lives in the product guides listed below.
Confluence has no separate product guide yet, so Confluence-specific settings (space permissions, anonymous access) remain in this hub.
A Note on Licensing: Atlassian Guard
Many of the controls below are delivered by Atlassian Guard, the identity and data-security add-on that was previously named Atlassian Access. The product was renamed and split into two subscription levels, so older documentation and console screenshots referring to “Access” describe what is now Guard:
- Guard Standard — SSO enforcement, SCIM user provisioning and deprovisioning, Mobile application management (MAM) policies, API token controls, and the organization-wide audit log.
- Guard Premium — everything in Standard plus data classification, anomalous activity detection, content scanning, and SIEM webhook forwarding.
Guard is licensed per unique billable user across the organization and is independent of the Jira or Confluence product edition, so a Premium product plan alone does not grant these capabilities. Source: Atlassian Guard pricing
Table of Contents
- Authentication & Access Controls
- Marketplace App Security
- API & Integration Security
- Data Security
- Monitoring & Detection
- Vulnerability Management
- Compliance Quick Reference
Products in This Platform
Atlassian is a multi-product platform. This guide is the Common Controls hub — the organization-wide controls configured once at admin.atlassian.com and inherited by every Atlassian product. Product-specific controls live in their own guides:
| Product | Guide | Covers |
|---|---|---|
| Common Controls (this guide) | — | Organization authentication & Atlassian Guard, IP allowlisting, Marketplace app governance, API tokens & OAuth, data residency & data security policies, organization audit log and anomaly detection |
| Jira Cloud | Jira Cloud guide | Permission schemes, work item security schemes, global permissions, public/anonymous space exposure, guest access, automation rule egress |
| Bitbucket | Bitbucket guide | Workspace membership & invitations, project permissions, workspace app access rules, forking, branch restrictions & merge checks, signed commits, Pipelines secrets and deployments |
| Confluence | — | No product guide yet — Confluence space permissions and anonymous access are covered in this hub (§1.2) |
Moved controls: organization-level controls that previously appeared in the product guides now live here and are not duplicated. From the former Jira Cloud guide: SAML SSO (§1.1), authentication policies (§1.1), two-step verification (§1.1), SAML JIT/SCIM provisioning (§1.1), domain verification (§1.1), Atlassian Guard licensing (§1.1 and A Note on Licensing above), organization admin roles (§1.2), third-party app access (§2.1, §3.3), audit logging (§5.1), and security alerting (§5.2). From the former Bitbucket guide: two-step verification and SAML SSO (§1.1), IP allowlisting (§1.4), and organization audit logging (§5.1).
1. Authentication & Access Controls
1.1 Enforce SSO with MFA
Profile Level: L1 (Crawl) CIS Controls: 6.3, 6.5 NIST 800-53: IA-2(1)
Description
Require SAML SSO with MFA for all Atlassian Cloud access, eliminating local password authentication. SAML configuration and enforced authentication policies are Atlassian Guard Standard capabilities (formerly sold as Atlassian Access) and are not tied to the Jira or Confluence product edition. Source: Atlassian Guard pricing
Prerequisites
- Atlassian organization with at least one verified domain
- Atlassian Guard Standard subscription (SAML SSO and enforced authentication policies are Guard capabilities, not product-edition features)
- Organization admin access
- SAML 2.0 compatible identity provider
Rationale
Why This Matters:
- Centralizing Atlassian authentication in your corporate IdP means MFA, conditional access, and session policy are enforced on every login to every Atlassian product, instead of product by product
- Configuring SSO alone does not force anyone to use it — without an authentication policy, a managed user can still sign in with a local Atlassian password and bypass every IdP control
- Domain verification is the prerequisite that turns accounts on your domain into managed accounts; unclaimed accounts are shadow identities that sit outside your authentication policy and your offboarding process
- Jira holds vulnerability tickets, roadmaps, and customer issues, and Confluence holds architecture documentation and credentials pasted into pages, so one compromised login exposes a great deal
- SSO plus directory provisioning gives one place to revoke access instantly when someone leaves, eliminating standing access through local credentials
Attack Prevented: Credential stuffing, phishing, password reuse, SSO bypass via local passwords, shadow accounts on your own domain, offboarding gaps
ClickOps Implementation (Atlassian Cloud)
Step 1: Verify Your Domains
- Navigate to: admin.atlassian.com → Directory → Domains
- Add each domain your organization owns and complete DNS verification
- Claim the existing Atlassian accounts on those domains into organization management — until an account is claimed, no authentication policy applies to it
- Repeat for every domain and subdomain in use, including domains acquired through mergers, which are the ones most often missed
Step 2: Configure SAML SSO
- Navigate to: admin.atlassian.com → Security → SAML single sign-on
- Click Add SAML configuration
- Configure:
- Identity provider: Your IdP (Okta, Entra ID, etc.)
- Entity ID: From IdP
- SSO URL: IdP login endpoint
- Upload IdP certificate
Step 3: Enforce SSO with an Authentication Policy
- Navigate to: Security → Authentication policies
- Create policy:
- Name: “SSO Required”
- Members: All managed users from verified domains
- Settings:
- Require SSO: Enabled
- Allow local passwords: Disabled
- Create a separate break-glass policy covering two or three dedicated organization-admin accounts that are exempt from SSO enforcement, so an IdP outage or misconfiguration cannot lock every administrator out. Protect those accounts with their own strong two-step verification and monitor every use of them in the audit log (§5.1)
Step 4: Configure Two-Step Verification
- Navigate to: Security → Two-step verification
- Enable: Require two-step verification for all users
- Configure:
- Enforcement: Required
- Grace period: None (L2)
- Where the IdP is authoritative, enforce phishing-resistant factors (FIDO2 security keys or passkeys) there for administrators rather than relying on TOTP alone
Step 5: Automate Provisioning and Deprovisioning
- Connect a directory and enable SCIM user provisioning so accounts, group membership, and — critically — deactivation flow from the IdP automatically
- Where SCIM is not available for a given IdP, enable SAML Just-In-Time (JIT) provisioning so accounts are created from authoritative IdP attributes on first SSO login rather than by hand
- Map IdP attributes and groups to Atlassian groups, then verify that the mapped groups are the same ones your product access grants and permission schemes rely on
- Test the full lifecycle end to end: create a test identity in the IdP, confirm it appears and receives the right product access, then disable it in the IdP and confirm Atlassian access is revoked
JIT is not deprovisioning. JIT provisioning only creates accounts at login; it never removes them. An organization relying on JIT alone accumulates active accounts for departed employees. Use SCIM where the IdP supports it, and where it does not, pair JIT with a scheduled manual deactivation review.
Code Implementation
SAML configuration and authentication policy settings have no resource in any of Atlassian’s cloud admin REST APIs (Cloud admin REST APIs, 2026-09-24). The Admin Control API can only add users to an existing authentication policy and report which policy each managed user is on, so Steps 2 and 3 stay ClickOps. The pack below reads what the Organizations API does expose: whether every domain is verified, which active managed accounts have no two-step verification, and how many active unmanaged accounts remain. Accounts under an SSO-enforced authentication policy get MFA from the IdP and can legitimately show no Atlassian two-step verification, so reconcile that list against the policy’s members.
Code Pack: API Script
# GET with the key in a process-substitution header file (never in argv).
# Any non-200 is "could not audit", never "no finding".
api_get() {
local resp code
resp=$(curl -sS --max-time 60 -w '\n%{http_code}' \
-H @<(printf 'Authorization: Bearer %s\n' "$ATLASSIAN_ORG_API_KEY") \
-H 'Accept: application/json' "${API}$1") || { echo "ERROR: GET ${1%%\?*} failed in transport" >&2; return 2; }
code=${resp##*$'\n'}
[ "$code" = "200" ] || { echo "ERROR: GET ${1%%\?*} -> HTTP ${code}" >&2; return 2; }
printf '%s\n' "${resp%$'\n'*}"
}
# jq always reads through a pipe. No here-strings: a here-string needs a temp
# file, and if that cannot be created the audit would read "no data" as clean.
rc=0
# 1. Domains. links.next is a URL; its cursor= value feeds the documented cursor param.
domains='[]'; cursor=''; pages=0
while :; do
page=$(api_get "/admin/v1/orgs/${ORG_ID}/domains${cursor:+?cursor=${cursor}}")
domains=$(printf '%s' "$page" | jq -ce --argjson acc "$domains" 'if (.data | type) == "array" then $acc + .data else error("no data array") end')
next=$(printf '%s' "$page" | jq -r '.links.next // empty')
[ -z "$next" ] && break
cursor=$(printf '%s' "$next" | sed -n 's/.*[?&]cursor=\([^&]*\).*/\1/p')
[ -n "$cursor" ] || { echo "ERROR: links.next carries no cursor; domain list is partial" >&2; exit 3; }
pages=$((pages + 1)); [ "$pages" -lt "$MAX_PAGES" ] || { echo "ERROR: MAX_PAGES reached; domain list is partial" >&2; exit 3; }
done
n_dom=$(printf '%s' "$domains" | jq 'length')
[ "$n_dom" -gt 0 ] || { echo "ERROR: the organization returned 0 domains; nothing to evaluate (verify a domain first)" >&2; exit 2; }
unverified=$(printf '%s' "$domains" | jq -r '.[] | select((.attributes.claim.status // "missing") != "verified")
| " \(.attributes.name // .id) claim.status=\(.attributes.claim.status // "missing")"')
echo "domains: ${n_dom}"
if [ -n "$unverified" ]; then
echo "FINDING: domains not in claim.status=verified (their accounts sit outside authentication policies):"
printf '%s\n' "$unverified"; rc=1
fi
# 2. Directory users (directoryId '-' = every directory). links.next is a cursor
# value. $1 = filter query string; prints one accountId per line. A page
# without a data array is "could not audit": it must never count as 0 users.
list_users() {
local q="$1" c='' p=0 pg nx
while :; do
pg=$(api_get "/admin/v2/orgs/${ORG_ID}/directories/-/users?limit=100&${q}${c:+&cursor=$(jq -rn --arg v "$c" '$v|@uri')}") || return 2
printf '%s' "$pg" | jq -r 'if (.data | type) == "array" then .data[] | (.accountId // error("user without accountId")) else error("no data array") end' \
|| { echo "ERROR: users response was not the documented JSON; nothing was audited" >&2; return 2; }
nx=$(printf '%s' "$pg" | jq -r '.links.next // empty') || return 2
[ -z "$nx" ] && return 0
c="$nx"; p=$((p + 1)); [ "$p" -lt "$MAX_PAGES" ] || { echo "ERROR: MAX_PAGES reached; user list is partial" >&2; return 3; }
done
}
no2sv=$(list_users 'claimStatus=managed&mfaEnabled=false&status=active')
n_no2sv=$(printf '%s' "$no2sv" | grep -c . || true)
echo "active managed accounts without two-step verification: ${n_no2sv}"
if [ "$n_no2sv" -gt 0 ]; then
echo "FINDING: reconcile these against your SSO-enforced authentication policy members:"
printf '%s\n' "$no2sv" | sed 's/^/ accountId=/'; rc=1
fi
unmanaged=$(list_users 'claimStatus=unmanaged&status=active')
n_unmanaged=$(printf '%s' "$unmanaged" | grep -c . || true)
echo "REVIEW: active unmanaged accounts (guests, or accounts on a domain you have not claimed): ${n_unmanaged}"
exit "$rc"
Validation & Testing
- From a browser with no existing session, sign in as a managed user and confirm you are redirected to the IdP and cannot reach a local Atlassian password prompt
- Attempt a password reset for a managed user and confirm the flow is unavailable or has no effect on access
- Confirm every verified domain lists zero unclaimed accounts under Directory → Managed accounts
- Deactivate a test identity in the IdP and confirm the corresponding Atlassian account loses access without any admin action
- Confirm the break-glass policy contains only the intended accounts and that every sign-in using it appears in the organization audit log
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC6.1 | Logical access controls |
| NIST 800-53 | IA-2(1) | MFA for network access |
1.2 Implement Granular Product Access and Limit Organization Admins
Profile Level: L1 (Crawl) NIST 800-53: AC-3, AC-6, AC-6(1)
Description
Configure who receives access to each Atlassian product at the organization level, and hold the organization administrator role to the smallest workable set of accounts. Product access is granted at admin.atlassian.com → Products; administrator roles are managed at admin.atlassian.com → Directory → Administrators. Within-product authorization (Jira permission schemes, Bitbucket workspace and project permissions) is configured in each product and is covered in the product guides.
Rationale
Why This Matters:
- Default-open product access lets every licensed user reach every Jira space and Confluence space, far beyond what their role requires
- Organization admins can change SSO configuration, authentication policies, product access grants, and billing across every Atlassian product, which makes the role the single highest-value target in the tenant — an attacker who phishes one org admin owns everything downstream
- Product admins can perform most day-to-day administration without organization-wide authority, so most people currently holding org admin almost certainly do not need it
- Disabling anonymous Confluence access prevents unauthenticated readers from harvesting internal documentation, architecture diagrams, and secrets pasted into pages
- Reviewing admin rosters on a cadence stops the privilege accumulation that follows reorganizations and offboarding gaps
Attack Prevented: Lateral movement, privilege creep, organization-admin account takeover, excessive standing privilege, insider data harvesting, anonymous information disclosure
ClickOps Implementation
Step 1: Configure Product Access
- Navigate to: admin.atlassian.com → Products
- For each product, configure:
- Default access: Disabled (users must be granted access)
- User access: Specific groups only
- Grant access through IdP-synced groups rather than to individuals, so access follows the joiner/mover/leaver process automatically
Step 2: Limit Organization Administrators
- Navigate to: admin.atlassian.com → Directory → Administrators
- Record every account holding the organization admin role and the business reason for each
- Reduce the roster to the minimum that still supports coverage — two or three accounts is typical, and those should be dedicated admin identities rather than the everyday accounts of the same people
- Move anyone whose work is confined to one product to a product admin role instead
- Re-review the roster on a fixed cadence and alert on organization admin role grants in the audit log (§5.1), since a self-granted admin role is a hallmark of an in-progress compromise
Step 3: Configure Confluence Space Permissions
- Navigate to: Confluence → Space settings → Permissions
- Configure:
- Anonymous access: Disabled
- Group permissions: Specific groups per space
- Default permissions: View only for most users
Within-product authorization lives in the product guides. Jira permission schemes, work item security schemes, and global permissions are covered in the Jira Cloud guide. Bitbucket workspace membership, project permissions, and branch restrictions are covered in the Bitbucket guide. Grant product access here first; scope it inside the product there.
Code Implementation
The pack counts active organization admins against a limit you set (Step 2). When a Confluence site is supplied, it also lists spaces where anonymous users hold a role (Step 3). Atlassian documents that space role-assignment endpoint only for sites with role-based access control, and the pack stops with an error rather than report a space as closed when the endpoint is unavailable.
Code Pack: API Script
# $1 = base URL, $2 = path+query, $3 = Authorization header value (built
# inside the process substitution, so no secret ever reaches argv).
http_get() {
local resp code
resp=$(curl -sS --max-time 60 -w '\n%{http_code}' -H @<(printf 'Authorization: %s\n' "$3") \
-H 'Accept: application/json' "$1$2") || { echo "ERROR: GET ${2%%\?*} failed in transport" >&2; return 2; }
code=${resp##*$'\n'}
[ "$code" = "200" ] || { echo "ERROR: GET ${2%%\?*} -> HTTP ${code}" >&2; return 2; }
printf '%s\n' "${resp%$'\n'*}"
}
org_get() { http_get "$API" "$1" "Bearer ${ATLASSIAN_ORG_API_KEY}"; }
site_get() { http_get "$ATLASSIAN_SITE_URL" "$1" \
"Basic $(printf '%s:%s' "$ATLASSIAN_EMAIL" "$ATLASSIAN_API_TOKEN" | base64 | tr -d '\n')"; }
rc=0
# Part 1: org admins. links.next on this resource is a cursor value.
admins=''; cursor=''; pages=0
while :; do
page=$(org_get "/admin/v2/orgs/${ORG_ID}/directories/-/users?limit=100&roleIds=atlassian/org-admin&status=active${cursor:+&cursor=$(jq -rn --arg v "$cursor" '$v|@uri')}")
# A page without a data array stops the audit (jq error); it never counts as 0 admins.
admins="${admins}$(printf '%s' "$page" | jq -r 'if (.data | type) == "array" then .data[] | (.accountId // error("user without accountId")) else error("no data array") end')"$'\n'
cursor=$(printf '%s' "$page" | jq -r '.links.next // empty')
[ -z "$cursor" ] && break
pages=$((pages + 1)); [ "$pages" -lt "$MAX_PAGES" ] || { echo "ERROR: MAX_PAGES reached; admin list is partial" >&2; exit 3; }
done
n_admins=$(printf '%s' "$admins" | grep -c . || true)
[ "$n_admins" -gt 0 ] || { echo "ERROR: 0 active org admins returned; the roleIds filter or key scope did not apply" >&2; exit 2; }
echo "active organization admins: ${n_admins} (limit ${MAX_ORG_ADMINS})"
printf '%s' "$admins" | grep . | sed 's/^/ accountId=/'
if [ "$n_admins" -gt "$MAX_ORG_ADMINS" ]; then
echo "FINDING: more org admins than MAX_ORG_ADMINS; move product-only admins to product admin roles"; rc=1
fi
# Part 2 (optional): Confluence spaces where anonymous users hold a role.
if [ -n "${ATLASSIAN_SITE_URL:-}" ]; then
: "${ATLASSIAN_EMAIL:?set ATLASSIAN_EMAIL for the Confluence check}"
: "${ATLASSIAN_API_TOKEN:?set ATLASSIAN_API_TOKEN for the Confluence check}"
spaces=''; cursor=''; pages=0
while :; do
page=$(site_get "/wiki/api/v2/spaces?status=current&limit=250${cursor:+&cursor=${cursor}}")
spaces="${spaces}$(printf '%s' "$page" | jq -r 'if (.results | type) == "array" then .results[] | "\(.id) \(.key)" else error("no results array") end')"$'\n'
next=$(printf '%s' "$page" | jq -r '._links.next // empty')
[ -z "$next" ] && break
cursor=$(printf '%s' "$next" | sed -n 's/.*[?&]cursor=\([^&]*\).*/\1/p')
[ -n "$cursor" ] || { echo "ERROR: _links.next carries no cursor; space list is partial" >&2; exit 3; }
pages=$((pages + 1)); [ "$pages" -lt "$MAX_PAGES" ] || { echo "ERROR: MAX_PAGES reached; space list is partial" >&2; exit 3; }
done
n_spaces=$(printf '%s' "$spaces" | grep -c . || true)
[ "$n_spaces" -gt 0 ] || { echo "ERROR: 0 current spaces visible to this account; nothing was audited" >&2; exit 2; }
open_spaces=''
while read -r sid skey; do
[ -n "$sid" ] || continue
ra=$(site_get "/wiki/api/v2/spaces/${sid}/role-assignments?principal-type=ACCESS_CLASS&principal-id=anonymous-users")
# An unexpected shape is an error (jq -e), never an implicit "0 assignments".
n_anon=$(printf '%s' "$ra" | jq -e '.results | if type == "array" then length else error("no results array") end')
[ "$n_anon" -eq 0 ] || open_spaces="${open_spaces} ${skey}"$'\n'
done < <(printf '%s' "$spaces")
echo "confluence spaces checked for anonymous access: ${n_spaces}"
if [ -n "$open_spaces" ]; then
echo "FINDING: anonymous users hold a role in these spaces:"; printf '%s' "$open_spaces"; rc=1
fi
else
echo "NOT CHECKED: Confluence anonymous access (set ATLASSIAN_SITE_URL, ATLASSIAN_EMAIL, ATLASSIAN_API_TOKEN)" >&2
fi
exit "$rc"
1.3 Configure API Token Policies
Profile Level: L1 (Crawl) NIST 800-53: IA-5
Description
Control API token creation and set an organization expiration policy that is shorter than the platform ceiling. Since 15 December 2024, every newly created Atlassian account API token must carry an expiry date between 1 day and 1 year. From 13 March 2025, Atlassian assigned a one-year expiry to tokens created before 15 December 2024, so those legacy tokens expired between 14 March and 12 May 2026. The platform therefore guarantees only a one-year maximum lifetime — a shorter organizational standard is a policy decision you still have to make and enforce. Source: Manage API tokens for your Atlassian account
Rationale
Why This Matters:
- API tokens authenticate as the user and bypass interactive SSO and MFA, so an exposed token is a standing credential carrying the full access of its owner
- The mandatory-expiry change removes the worst case of a token that never dies, but a token living the full one-year platform maximum still gives an attacker a very long window of persistent access
- Long-lived tokens linger in scripts, CI systems, and developer laptops, and a one-year rotation cadence rarely matches the pace at which contractors leave and repositories get forked
- Restricting who can mint tokens and setting a shorter internal expiration standard shrinks the window an exfiltrated token remains valid
- Auditing and revoking unused tokens removes forgotten credentials that attackers routinely find in code repositories and config files
- Tokens that silently expire on the platform’s schedule can break production integrations, so tracking expiry dates is an availability concern as much as a security one
Attack Prevented: Token theft, MFA bypass, persistent unauthorized access, credential sprawl
ClickOps Implementation
Step 1: Configure Token Settings
- Navigate to: admin.atlassian.com → Security → API tokens
- Configure:
- Allow users to create API tokens: Controlled (requires Atlassian Guard)
- Token expiration: Choose the shortest interval your integrations tolerate. Atlassian accepts 1 day to 1 year; a 90-day organizational standard is a policy choice well inside that ceiling, not the platform maximum
Step 2: Audit Existing Tokens
- Navigate to: Security → API tokens → Token controls
- Review active tokens, recording the expiry date on each one
- Revoke unused or suspicious tokens
- Flag any token whose expiry sits near the one-year ceiling for early rotation and for migration to a scoped OAuth 2.0 integration where one exists
Step 3: Plan for Forced Expiry
- Inventory every automation, CI job, and script that authenticates with a personal API token
- Assign each an owner responsible for rotation ahead of the expiry date
- Alert on authentication failures from service integrations so an expired token surfaces as a tracked incident rather than a silent outage
Code Implementation
The Allow users to create API tokens setting has no REST resource (Cloud admin REST APIs, 2026-09-24), so Step 1 stays ClickOps. The pack audits Step 2 through the API Access REST API: it lists every user API token in the organization and flags any allowed token with no expiry, or with an expiry beyond your organizational standard.
Code Pack: API Script
api_get() { # key in a process-substitution header file, never in argv; non-200 -> 2
local resp code
resp=$(curl -sS --max-time 60 -w '\n%{http_code}' \
-H @<(printf 'Authorization: Bearer %s\n' "$ATLASSIAN_ORG_API_KEY") \
-H 'Accept: application/json' "${API}$1") || { echo "ERROR: GET ${1%%\?*} failed in transport" >&2; return 2; }
code=${resp##*$'\n'}
[ "$code" = "200" ] || { echo "ERROR: GET ${1%%\?*} -> HTTP ${code} (403 = the key lacks permission to read user API tokens: read:tokens:admin)" >&2; return 2; }
printf '%s\n' "${resp%$'\n'*}"
}
tokens='[]'; cursor=''; pages=0
while :; do
page=$(api_get "/admin/api-access/v1/orgs/${ORG_ID}/api-tokens?pageSize=1000${cursor:+&cursor=$(jq -rn --arg v "$cursor" '$v|@uri')}")
tokens=$(printf '%s' "$page" | jq -ce --argjson acc "$tokens" 'if (.data | type) == "array" then $acc + .data else error("no data array") end')
cursor=$(printf '%s' "$page" | jq -r '.links.next // empty')
[ -z "$cursor" ] && break
pages=$((pages + 1)); [ "$pages" -lt "$MAX_PAGES" ] || { echo "ERROR: MAX_PAGES reached; token list is partial" >&2; exit 3; }
done
# expiresAt is ISO-8601; fractional seconds and a +00:00 offset are normalised
# before parsing, and anything still unparseable is flagged, not skipped.
limit=$(( $(date +%s) + MAX_TOKEN_DAYS * 86400 ))
findings=$(printf '%s' "$tokens" | jq -r --argjson lim "$limit" '
.[] | select(.status == "ALLOWED") | (.expiresAt // "") as $e
| (if $e == "" then "no-expiry"
else (try ($e | sub("\\.[0-9]+"; "") | sub("\\+00:00$"; "Z") | fromdateiso8601) catch -1) as $t
| if $t < 0 then "unparseable-expiry" elif $t > $lim then "expiry-beyond-standard" else empty end
end) as $why
| " user=\(.user.id) token=\(.label) created=\(.createdAt) expires=\(if $e == "" then "never" else $e end) lastActive=\(.lastActiveAt // "never") -> \($why)"')
echo "API tokens: $(printf '%s' "$tokens" | jq 'length') ($(printf '%s' "$tokens" | jq '[.[] | select(.status == "ALLOWED")] | length') allowed); standard: expiry within ${MAX_TOKEN_DAYS} days"
if [ -n "$findings" ]; then
echo "FINDING: tokens to rotate or revoke:"; printf '%s\n' "$findings"; exit 1
fi
exit 0
1.4 Restrict Product Access with IP Allowlisting
Profile Level: L3 (Run)
| Framework | Control ID |
|---|---|
| CIS Controls | 12.7, 13.4 |
| NIST 800-53 | AC-3, AC-17, SC-7 |
| ISO 27001:2022 | A.8.20, A.8.21 |
Description
Restrict browser and REST API access to Jira, Jira Service Management, and Confluence so that only requests originating from approved IP ranges — corporate egress, VPN concentrators, or managed CI runners — are accepted. IP allowlisting is configured per product under Product settings → Security → IP allowlist and is available on Premium and Enterprise product plans. Source: What is the scope of IP allowlists in Atlassian Cloud?
Rationale
Why This Matters:
- A stolen password or session cookie is useless from an attacker’s own infrastructure if the product rejects the connection before authentication is evaluated
- The allowlist applies to both interactive site login and REST API calls, so it constrains scripted data harvesting with a leaked API token as effectively as it constrains browser logins
- Network-level filtering runs independently of the identity provider, giving a second, non-credential control that survives an IdP compromise or an MFA-fatigue attack
- Bulk exfiltration campaigns typically run from cloud hosting or residential proxy ranges that never appear in a corporate egress allowlist, so the restriction cuts off the exfiltration path even after account takeover
Important scope limitation: the product IP allowlist does not cover admin.atlassian.com. Organization administration — user management, authentication policies, product access grants, and billing — remains reachable from any IP address. Treat administrator accounts as still fully internet-exposed and protect them with phishing-resistant MFA and dedicated authentication policies rather than assuming the allowlist covers them.
Attack Prevented: Credential-stuffing from attacker infrastructure, session hijacking replayed off-network, API-token-driven bulk export, unmanaged-device access
ClickOps Implementation
Step 1: Inventory Legitimate Source Ranges
- Collect corporate office egress IPs, VPN concentrator IPs, and the static egress ranges of any CI/CD system, integration platform, or monitoring tool that calls the Atlassian REST API
- Confirm each range is static — dynamic residential or auto-scaling cloud IPs will break access unpredictably
- Document the business owner for every range so stale entries can be removed later
Step 2: Configure the Allowlist per Product
- Navigate to: Product settings → Security → IP allowlist for Jira
- Add each approved IP address or CIDR range with a description identifying its owner
- Repeat for Jira Service Management and Confluence — the allowlist is configured separately for each product and is not inherited across them
- Review whether customer-facing Jira Service Management portals need to remain publicly reachable before enforcing on that product
Step 3: Compensate for the Admin Console Gap
- Apply a dedicated authentication policy to organization administrators requiring phishing-resistant MFA
- Keep the count of organization admins minimal and review it on a fixed cadence
- Alert on any administrative action in the audit log originating from an IP outside the allowlist, since the console itself will not block it
Code Implementation
The Admin Control REST API models IP allowlists as organization policies. The pack reads them and flags a missing, failed, or allow-everything policy, and lists any policy that lets mobile apps bypass the allowlist for review. It never creates or edits an allowlist, because an allowlist change can lock administrators out.
Code Pack: API Script
api_get() { # key in a process-substitution header file, never in argv; non-200 -> 2
local resp code
resp=$(curl -sS --max-time 60 -w '\n%{http_code}' \
-H @<(printf 'Authorization: Bearer %s\n' "$ATLASSIAN_ORG_API_KEY") \
-H 'Accept: application/json' "${API}$1") || { echo "ERROR: GET ${1%%\?*} failed in transport" >&2; return 2; }
code=${resp##*$'\n'}
[ "$code" = "200" ] || { echo "ERROR: GET ${1%%\?*} -> HTTP ${code} (403 = access forbidden: check the key carries read:policies:admin)" >&2; return 2; }
printf '%s\n' "${resp%$'\n'*}"
}
BASE="/admin/control/v1/orgs/${ORG_ID}/policies"
# Resources of one policy. Models.PolicyResourcePage is paged (meta.next, links.next),
# but this GET documents no cursor parameter, so a next page is fetched only through
# the links.next URL the API returns, and only on this API base (the key never leaves
# it). A next page that cannot be followed makes the list partial (exit 3): a failed
# application on an unread page must never read as "no finding".
policy_resources() { # $1 = policy id; prints a JSON array of {id, applicationStatus}
local path="${BASE}/$1/resources" acc='[]' pg nx mn p=0
while :; do
pg=$(api_get "$path") || return 2
acc=$(printf '%s' "$pg" | jq -ce --argjson acc "$acc" 'if (.data | type) == "array" then $acc + [.data[] | {id, applicationStatus}] else error("no data array") end') || return 5
nx=$(printf '%s' "$pg" | jq -r '.links.next // empty') || return 5
mn=$(printf '%s' "$pg" | jq -r '.meta.next // empty') || return 5
if [ -z "$nx" ] && [ -z "$mn" ]; then printf '%s\n' "$acc"; return 0; fi
case "$nx" in
"${API}"/admin/control/*) path="${nx#"$API"}" ;;
/admin/control/*) path="$nx" ;;
*) echo "ERROR: resources of policy $1 continue on a page this pack cannot follow; list is partial" >&2; return 3 ;;
esac
p=$((p + 1)); [ "$p" -lt "$MAX_PAGES" ] || { echo "ERROR: MAX_PAGES reached; resources of policy $1 are partial" >&2; return 3; }
done
}
# 1. Policies of this type; meta.next is a cursor value.
policies='[]'; cursor=''; pages=0
while :; do
page=$(api_get "${BASE}?type=ip-allowlist${cursor:+&cursor=$(jq -rn --arg v "$cursor" '$v|@uri')}")
policies=$(printf '%s' "$page" | jq -ce --argjson acc "$policies" 'if (.data | type) == "array" then $acc + .data else error("no data array") end')
cursor=$(printf '%s' "$page" | jq -r '.meta.next // empty')
[ -z "$cursor" ] && break
pages=$((pages + 1)); [ "$pages" -lt "$MAX_PAGES" ] || { echo "ERROR: MAX_PAGES reached; policy list is partial" >&2; exit 3; }
done
# 2. Where each policy has been applied (a separate resource in this API).
n=$(printf '%s' "$policies" | jq 'length'); i=0
while [ "$i" -lt "$n" ]; do
pid=$(printf '%s' "$policies" | jq -r --argjson i "$i" '.[$i].id')
st=$(policy_resources "$pid")
policies=$(printf '%s' "$policies" | jq -c --argjson i "$i" --argjson s "$st" '.[$i].appliedTo = $s')
i=$((i + 1))
done
printf '%s' "$policies" | jq -r '.[] | " \(.attributes.name // .id): status=\(.attributes.status // "unknown") ranges=\(.attributes.rule.in // [] | length) mobileBypass=\(.attributes.rule.allowMobileBypass | if . == null then "unset" else tostring end) resources=\(.appliedTo | map(.applicationStatus) | group_by(.) | map("\(.[0])=\(length)") | join(","))"'
findings=$(printf '%s' "$policies" | jq -r '
(if ([.[] | select(.attributes.status == "enabled")] | length) == 0
then "no enabled ip-allowlist policy: product access is not restricted by source IP" else empty end),
(.[] | select(.attributes.status == "enabled")
| select((.attributes.rule.in // []) | any(. == "0.0.0.0/0" or . == "::/0"))
| "\(.attributes.name // .id): enabled rule admits every address"),
(.[] | . as $p | .appliedTo[] | select(.applicationStatus == "failed")
| "\($p.attributes.name // $p.id): application failed on resource \(.id)")')
review=$(printf '%s' "$policies" | jq -r '.[] | select(.attributes.status == "enabled" and .attributes.rule.allowMobileBypass == true)
| "\(.attributes.name // .id): allowMobileBypass=true, confirm mobile apps are meant to bypass this allowlist"')
echo "ip-allowlist policies: ${n}"
[ -z "$review" ] || { echo "REVIEW:"; printf '%s\n' "$review" | sed 's/^/ /'; }
if [ -n "$findings" ]; then
echo "FINDING:"; printf '%s\n' "$findings" | sed 's/^/ /'; exit 1
fi
exit 0
Validation & Testing
- From an approved network, log in to each product and confirm normal access
- From an unapproved network — a mobile hotspot or a cloud VM — attempt to load the same site and confirm the request is refused
- From the unapproved network, issue an authenticated REST API call with a valid API token and confirm it is also refused, proving the allowlist covers the API surface and not just the browser
- From the unapproved network, load
admin.atlassian.comand confirm it is reachable, documenting this as an accepted residual risk with its compensating controls - Verify every allowlisted integration still functions after enforcement, particularly CI jobs that run on a schedule rather than on demand
- Re-run the external-network test after each allowlist change to confirm no entry silently widened the perimeter
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC6.1 | Logical access controls restrict access to authorized sources |
| SOC 2 | CC6.6 | Boundary protection for external access points |
| NIST 800-53 | AC-17 | Remote access restrictions and monitoring |
| NIST 800-53 | SC-7 | Boundary protection |
| ISO 27001:2022 | A.8.20 | Network security controls |
| CIS Controls | 12.7 | Manage network infrastructure access |
2. Marketplace App Security
2.1 Implement App Approval Workflow
Profile Level: L1 (Crawl) - CRITICAL NIST 800-53: CM-7
Description
Require admin approval for Marketplace app installation. Apps have broad access to Jira/Confluence data.
Rationale
Why This Matters:
- 6,000+ Marketplace apps with varying security postures
- Apps access project data, user information, and configurations
- Compromised or malicious apps enable data exfiltration
- The app framework matters as much as the vendor: Connect apps run on vendor-controlled infrastructure and hold broad JWT-scoped access, while Forge apps run on Atlassian-hosted infrastructure with tighter, declared permission scoping
- Connect is being retired on a published timeline, so approving a new Connect app today buys a dependency with a known end date
Attack Prevented: Malicious app installation, data exfiltration through over-scoped integrations, supply-chain compromise via unmaintained vendor infrastructure
ClickOps Implementation
Step 1: Configure App Installation Policy
- Navigate to: admin.atlassian.com → Security → App policies
- Configure:
- Who can install apps: Admins only
- User install requests: Require approval
- App block list: Add prohibited apps
Step 2: Review Existing Apps
- Navigate to: admin.atlassian.com → Apps
- For each app, review:
- Permissions/scopes requested
- Last updated date
- Security certifications
- User count and reviews
- Remove unused or suspicious apps
Step 3: Create App Evaluation Checklist Before approving any app:
- Review requested permissions (OAuth scopes)
- Check vendor security certifications (SOC 2, ISO 27001)
- Review app update frequency
- Check for known vulnerabilities
- Evaluate data access requirements
- Confirm which framework the app is built on — prefer Forge, and treat Connect as legacy (see below)
- Document business justification
Automation: ClickOps only — Atlassian exposes no write interface for this setting (Cloud admin REST APIs, 2026-09-24). None of the six cloud admin REST APIs has a resource for the app installation policy, user install requests, or the app block list. App access to covered content is restricted through data security policies instead (§4.3).
Connect Framework Sunset — Prefer Forge
Atlassian has published an end-of-support timeline for the Connect app framework, and it changes how new app approvals should be evaluated:
| Milestone | Date | Effect |
|---|---|---|
| No new Connect listings | September 2025 | New Connect apps can no longer be published to the Marketplace |
| Descriptor updates stop | March 2026 | Existing Connect apps can no longer change their descriptor, freezing scopes and endpoints |
| End of support | Q4 2026 | Connect apps enter an end-of-support state (enforcement begins Q4 CY2026) |
Source: Announcing Connect end of support timeline and next steps
What this means for app governance:
- Treat any Connect-based app as a legacy dependency with a hard expiry, not a durable integration, and require a documented migration or replacement plan before approving it
- Prefer Forge apps, which run on Atlassian-hosted infrastructure with declared, granular permission scopes and no vendor-operated servers holding your data in transit
- Because Connect descriptors freeze in March 2026, a Connect app cannot narrow its own scopes or repoint its endpoints after that date — any scope concern you have with it becomes permanent
- Inventory your installed Connect apps now and track vendor migration commitments; apps whose vendors have not announced a Forge migration path should be scheduled for removal rather than carried toward the end-of-support deadline
- Frozen, unmaintained apps still holding live tokens against your instance are an attractive supply-chain target, so shrink that inventory ahead of end-of-support rather than at it
Marketplace App Risk Assessment
| Permission | Risk Level | Questions to Ask |
|---|---|---|
| Read Jira issues | Medium | Which projects? |
| Write Jira issues | High | Can it delete? |
| Read Confluence content | Medium | Which spaces? |
| Admin access | Critical | Why needed? |
| User management | Critical | Business justification? |
| Act on behalf of users | High | What actions? |
2.2 Monitor App Activity
Profile Level: L2 (Walk) NIST 800-53: AU-6
Description
Monitor Marketplace app API calls and data access.
Rationale
Why This Matters:
- Installed apps run with broad delegated scopes and can read or move large volumes of Jira and Confluence data without a human in the loop
- A compromised or malicious app behaves like a legitimate integration, so its abuse is invisible without monitoring API call patterns
- Baselining normal app data-access volume surfaces sudden bulk reads or exports that indicate exfiltration in progress
- Activity logs give responders the evidence to scope and revoke a rogue app before it drains an entire instance
Attack Prevented: Malicious app data exfiltration, supply-chain abuse, undetected bulk export
ClickOps Implementation
- Navigate to: admin.atlassian.com → Security → Audit log (the organization audit log; requires Atlassian Guard — the same surface as §5.1)
- Filter the activity to app-related events — app installs, updates, removals, and app access changes — for the last 30 days
- Investigate any install or scope change without a matching approval from §2.1, and any app whose data-access volume departs from its baseline
- Cross-check the installed-app inventory at admin.atlassian.com → Apps
Code Implementation
The pack discovers the app-related actions from the audit log’s own action catalogue, then prints the matching events for the look-back window. Any response other than HTTP 200 stops it rather than reading as “no activity”. A look-back window with no audit events of any kind is reported as inconclusive, because an empty log shows nothing about apps and Atlassian’s reference does not say what these endpoints return to an organization without Atlassian Guard.
Code Pack: API Script
api_get() { # $1 = path + query; prints the body; returns 2 on any non-200
local resp code
resp=$(curl -sS --max-time 60 -w '\n%{http_code}' \
-H @<(printf 'Authorization: Bearer %s\n' "$ATLASSIAN_ORG_API_KEY") \
-H 'Accept: application/json' "${API}$1") || { echo "ERROR: GET ${1%%\?*} failed in transport" >&2; return 2; }
code=${resp##*$'\n'}
if [ "$code" != "200" ]; then
echo "ERROR: GET ${1%%\?*} -> HTTP ${code} (check the key is valid and carries read:events:admin)" >&2
return 2
fi
printf '%s\n' "${resp%$'\n'*}"
}
# 1. Discover app-related action ids from the documented catalogue.
catalogue=$(api_get "/admin/v1/orgs/${ORG_ID}/event-actions")
app_ids=$(printf '%s' "$catalogue" | jq -c '[.data[] | select((.attributes.displayName + " " + .attributes.groupDisplayName) | test("\\bapps?\\b"; "i")) | .id]')
n_ids=$(printf '%s' "$app_ids" | jq 'length')
if [ "$n_ids" -eq 0 ]; then
echo "ERROR: event-actions returned no app-related actions; nothing was audited" >&2
exit 2
fi
echo "app-related actions in catalogue: ${n_ids}" >&2
# 2. Page the audit log for the look-back window; keep only app actions.
# events-stream keeps returning a cursor at the end (it is a poll), so an
# empty page ends the walk.
from_ms=$(( ( $(date +%s) - LOOKBACK_DAYS * 86400 ) * 1000 ))
query="from=${from_ms}&limit=500"
pages=0; matched=0; scanned=0
while :; do
page=$(api_get "/admin/v1/orgs/${ORG_ID}/events-stream?${query}")
n_rows=$(printf '%s' "$page" | jq -e '.data | if type == "array" then length else error("no data array") end')
scanned=$(( scanned + n_rows ))
hits=$(printf '%s' "$page" | jq -c --argjson ids "$app_ids" \
'.data[] | select(.attributes.action as $a | $ids | index($a)) | {time: .attributes.time, action: .attributes.action, actor: .attributes.actor.id}')
if [ -n "$hits" ]; then printf '%s\n' "$hits"; matched=$(( matched + $(printf '%s\n' "$hits" | grep -c .) )); fi
pages=$(( pages + 1 ))
next=$(printf '%s' "$page" | jq -r '.meta.next // empty')
if [ -z "$next" ] || [ "$n_rows" -eq 0 ]; then break; fi
if [ "$pages" -ge "$MAX_PAGES" ]; then
echo "ERROR: stopped at MAX_PAGES=${MAX_PAGES}; results are partial (raise MAX_PAGES or shorten LOOKBACK_DAYS)" >&2
exit 3
fi
query="from=${from_ms}&limit=500&cursor=$(jq -rn --arg c "$next" '$c|@uri')"
sleep "$PAGE_DELAY"
done
echo "pages read: ${pages}; events scanned: ${scanned}; app events in last ${LOOKBACK_DAYS} days: ${matched}" >&2
if [ "$scanned" -eq 0 ]; then
echo "INCONCLUSIVE: the audit log returned no events at all in the window, so it shows nothing about apps" >&2
exit 4
fi
3. API & Integration Security
3.1 Secure AppLinks Configuration
Profile Level: L2 (Walk) NIST 800-53: SC-8
Description
Harden AppLinks between Atlassian products to prevent impersonation attacks.
Rationale
Why This Matters:
- AppLinks establish trust between Atlassian products, and a misconfigured link lets one application impersonate users on another
- OAuth 1.0a and unverified trust directions allow an attacker who controls one linked system to forge requests the trusting system accepts as authenticated
- Auditing and removing stale AppLinks eliminates standing trust relationships to systems that are no longer maintained or have changed ownership
- Restricting AppLink creation to administrators prevents an attacker or insider from quietly establishing a trusted impersonation channel
Attack Prevented: User impersonation, cross-product request forgery, trust-relationship abuse
ClickOps Implementation (Data Center)
Step 1: Audit Existing AppLinks
- Navigate to: Administration → Application links
- Review all configured links
- Verify each link is still needed
Step 2: Configure OAuth 2.0 for AppLinks
- For each AppLink, configure:
- Authentication type: OAuth 2.0 (not OAuth 1.0a)
- Incoming/Outgoing trust: Verify both directions
Step 3: Restrict AppLinks Creation
- Limit AppLinks creation to administrators only
- Document approved integration patterns
Code Implementation (Data Center)
The pack inventories every application link and flags any link that no longer works, which is standing trust to a system that no longer answers. It deliberately does not read a link’s authentication configuration: Atlassian notes that endpoint returns the stored username and password of a Basic Auth link (Application Links configuration summary, 2026-09-24). Confirm the OAuth 2.0 authentication type in the console (Step 2).
Code Pack: API Script
dc_get() { # $1 = path; basic auth built inside a process substitution (never argv); non-200 -> 2
local resp code
resp=$(curl -sS --max-time 60 -w '\n%{http_code}' \
-H @<(printf 'Authorization: Basic %s\n' "$(printf '%s:%s' "$DC_USERNAME" "$DC_PASSWORD" | base64 | tr -d '\n')") \
-H 'Accept: application/json' "${APPLINKS_BASE_URL}$1") || { echo "ERROR: GET $1 failed in transport" >&2; return 2; }
code=${resp##*$'\n'}
[ "$code" = "200" ] || { echo "ERROR: GET $1 -> HTTP ${code} (needs an administrator)" >&2; return 2; }
printf '%s\n' "${resp%$'\n'*}"
}
links=$(dc_get "/rest/applinks/3.0/applinks")
# error() exits jq non-zero, so an unexpected shape stops the audit; an empty array is a real "none".
ids=$(printf '%s' "$links" | jq -r 'if type == "array" then .[].id else error("link list is not an array") end')
n=$(printf '%s' "$ids" | grep -c . || true)
echo "application links: ${n}"
[ "$n" -gt 0 ] || { echo "no application links configured (nothing to trust, nothing to remove)"; exit 0; }
broken=''
while read -r id; do
[ -n "$id" ] || continue
st=$(dc_get "/rest/applinks/3.0/status/${id}")
# no -e here: jq -e treats a legitimate `false` as failure. error() still exits non-zero.
working=$(printf '%s' "$st" | jq -r 'if (.working | type) == "boolean" then .working else error("no working flag") end')
echo " ${id}: working=${working}"
[ "$working" = "true" ] || broken="${broken} ${id}"$'\n'
done < <(printf '%s\n' "$ids")
echo "REVIEW: confirm each link is still needed and uses OAuth 2.0 (console: Administration → Application links)"
if [ -n "$broken" ]; then
echo "FINDING: links that are not working (stale trust, remove or repair):"; printf '%s' "$broken"; exit 1
fi
exit 0
3.2 Configure Webhook Security
Profile Level: L2 (Walk) NIST 800-53: SC-8
Description
Secure webhook configurations to prevent data leakage.
Rationale
Why This Matters:
- Webhooks push issue and page data to external URLs automatically, so a hostile or hijacked endpoint receives a continuous feed of internal content
- Without HTTPS, webhook payloads traverse the network in cleartext and can be intercepted in transit
- Missing signature validation lets an attacker spoof webhook calls to downstream services or replay captured payloads
- Scoping events with JQL filters limits each webhook to the minimum data it needs, reducing what leaks if the destination is compromised
Attack Prevented: Data leakage, payload interception, webhook spoofing, over-broad data exposure
ClickOps Implementation
Step 1: Audit Webhooks
- Navigate to: Settings (⚙) → System → Advanced → WebHooks (Jira; requires the Administer Jira global permission)
- Review all configured webhooks:
- Destination URLs (should be internal or verified services)
- Events subscribed
- JQL filters (limit scope)
Step 2: Secure Webhook Endpoints
- Confirm every webhook has a secret set. Jira accepts only HTTPS destinations for admin webhooks, so the remaining risk is an unsigned delivery
- Implement webhook signature validation
- Limit events to necessary minimum
Code Implementation
The API pack audits Step 1 and the secret half of Step 2: it lists every admin webhook and flags one with no secret or a non-HTTPS destination. The SDK pack is the receiving side of Step 2: it verifies the X-Hub-Signature HMAC and rejects an unsigned delivery.
Code Pack: API Script
resp=$(curl -sS --max-time 60 -w '\n%{http_code}' \
-H @<(printf 'Authorization: Basic %s\n' "$(printf '%s:%s' "$ATLASSIAN_EMAIL" "$ATLASSIAN_API_TOKEN" | base64 | tr -d '\n')") \
-H 'Accept: application/json' "${ATLASSIAN_SITE_URL}/rest/webhooks/1.0/webhook") \
|| { echo "ERROR: GET /rest/webhooks/1.0/webhook failed in transport" >&2; exit 2; }
code=${resp##*$'\n'}; body=${resp%$'\n'*}
[ "$code" = "200" ] || { echo "ERROR: GET /rest/webhooks/1.0/webhook -> HTTP ${code} (needs a Jira administrator)" >&2; exit 2; }
# The list must be a JSON array; any other shape is "could not audit".
hooks=$(printf '%s' "$body" | jq -ce 'if type == "array" then . else error("webhook list is not an array") end') \
|| { echo "ERROR: unexpected response shape; nothing was audited" >&2; exit 2; }
echo "admin webhooks: $(printf '%s' "$hooks" | jq 'length')"
printf '%s' "$hooks" | jq -r '.[] | " \(.name // "(unnamed)"): enabled=\(.enabled) isSigned=\(.isSigned) url=\(.url)"'
findings=$(printf '%s' "$hooks" | jq -r '.[]
| (if .isSigned == true then empty else "\(.name // .self): no secret set, deliveries cannot be verified" end),
(if ((.url // "") | startswith("https://")) then empty else "\(.name // .self): destination is not https://" end)')
review=$(printf '%s' "$hooks" | jq -r '.[] | select(.enabled == true)
| select(((.filters // {}) | to_entries | map(.value // "") | join("")) == "")
| "\(.name // .self): no JQL filter, sends every matching event"')
[ -z "$review" ] || { echo "REVIEW:"; printf '%s\n' "$review" | sed 's/^/ /'; }
if [ -n "$findings" ]; then
echo "FINDING:"; printf '%s\n' "$findings" | sed 's/^/ /'; exit 1
fi
exit 0
Code Pack: SDK Script
import hmac
import hashlib
def validate_webhook(request, secret):
signature = request.headers.get('X-Hub-Signature')
if not isinstance(signature, str) or not signature.isascii():
return False # unsigned or malformed delivery: reject, never raise
payload = request.body
expected = 'sha256=' + hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
3.3 OAuth Token Management
Profile Level: L1 (Crawl) NIST 800-53: IA-5(13)
Description
Manage OAuth tokens for third-party integrations.
Rationale
Why This Matters:
- OAuth grants let third-party applications act on a user’s behalf, and forgotten authorizations remain valid until explicitly revoked
- Over-scoped grants give an integration far more access than it needs, so its compromise exposes data well beyond its function
- Requiring admin approval for new apps and sensitive scopes stops users from authorizing risky integrations that bypass procurement and security review
- Periodically reviewing and revoking connected apps removes dormant grants that attackers exploit through compromised third-party vendors
Attack Prevented: OAuth grant abuse, over-privileged integrations, third-party compromise, consent phishing
ClickOps Implementation
Step 1: Review Authorized Applications
- Navigate to: Profile → Security → Connected apps
- Review apps with OAuth access
- Revoke unnecessary authorizations
Step 2: Configure OAuth App Policies
- Navigate to: admin.atlassian.com → Security → External apps
- Configure:
- App approval: Required for new apps
- Scope review: Admin approval for sensitive scopes
Automation: ClickOps only — Atlassian exposes no write interface for this setting (Cloud admin REST APIs, 2026-09-24). None of the six cloud admin REST APIs has a resource for the apps users have authorized; the API Access REST API covers OAuth clients for service accounts, not connected apps. Authorized applications can be reviewed and revoked only in the console.
4. Data Security
4.1 Configure Data Residency
Profile Level: L2 (Walk) NIST 800-53: SC-28
Description
Configure data residency for compliance with data localization requirements.
Rationale
Why This Matters:
- Regulations such as GDPR and various data-sovereignty laws require certain data to remain within specific geographic boundaries
- Pinning Atlassian data to an approved realm prevents inadvertent cross-border storage that creates legal and contractual exposure
- Documented residency controls provide the evidence auditors and customers require to demonstrate localization compliance
- Controlling where data lives also limits which jurisdictions’ legal processes can compel access to it
Attack Prevented: Compliance violations, unauthorized cross-border data transfer, jurisdictional overreach
ClickOps Implementation (Standard, Premium and Enterprise plans)
Step 1: Configure Data Residency
- Navigate to: admin.atlassian.com → Data residency
- Select realm for data storage:
- US
- EU
- Australia
- Apply to products
Code Implementation
The Admin Control REST API exposes data residency as an organization policy. The pack reads those policies, flags a failed realm move, and, when you give it your required realm, flags a product pinned anywhere else. Moving a product between realms is a scheduled migration, so the pack never creates or edits a policy.
Code Pack: API Script
api_get() { # key in a process-substitution header file, never in argv; non-200 -> 2
local resp code
resp=$(curl -sS --max-time 60 -w '\n%{http_code}' \
-H @<(printf 'Authorization: Bearer %s\n' "$ATLASSIAN_ORG_API_KEY") \
-H 'Accept: application/json' "${API}$1") || { echo "ERROR: GET ${1%%\?*} failed in transport" >&2; return 2; }
code=${resp##*$'\n'}
[ "$code" = "200" ] || { echo "ERROR: GET ${1%%\?*} -> HTTP ${code} (403 = access forbidden: check the key carries read:policies:admin)" >&2; return 2; }
printf '%s\n' "${resp%$'\n'*}"
}
BASE="/admin/control/v1/orgs/${ORG_ID}/policies"
# Resources of one policy. Models.PolicyResourcePage is paged (meta.next, links.next),
# but this GET documents no cursor parameter, so a next page is fetched only through
# the links.next URL the API returns, and only on this API base (the key never leaves
# it). A next page that cannot be followed makes the list partial (exit 3): a failed
# application on an unread page must never read as "no finding".
policy_resources() { # $1 = policy id; prints a JSON array of {id, applicationStatus}
local path="${BASE}/$1/resources" acc='[]' pg nx mn p=0
while :; do
pg=$(api_get "$path") || return 2
acc=$(printf '%s' "$pg" | jq -ce --argjson acc "$acc" 'if (.data | type) == "array" then $acc + [.data[] | {id, applicationStatus}] else error("no data array") end') || return 5
nx=$(printf '%s' "$pg" | jq -r '.links.next // empty') || return 5
mn=$(printf '%s' "$pg" | jq -r '.meta.next // empty') || return 5
if [ -z "$nx" ] && [ -z "$mn" ]; then printf '%s\n' "$acc"; return 0; fi
case "$nx" in
"${API}"/admin/control/*) path="${nx#"$API"}" ;;
/admin/control/*) path="$nx" ;;
*) echo "ERROR: resources of policy $1 continue on a page this pack cannot follow; list is partial" >&2; return 3 ;;
esac
p=$((p + 1)); [ "$p" -lt "$MAX_PAGES" ] || { echo "ERROR: MAX_PAGES reached; resources of policy $1 are partial" >&2; return 3; }
done
}
policies='[]'; cursor=''; pages=0
while :; do
page=$(api_get "${BASE}?type=data-residency${cursor:+&cursor=$(jq -rn --arg v "$cursor" '$v|@uri')}")
policies=$(printf '%s' "$page" | jq -ce --argjson acc "$policies" 'if (.data | type) == "array" then $acc + .data else error("no data array") end')
cursor=$(printf '%s' "$page" | jq -r '.meta.next // empty')
[ -z "$cursor" ] && break
pages=$((pages + 1)); [ "$pages" -lt "$MAX_PAGES" ] || { echo "ERROR: MAX_PAGES reached; policy list is partial" >&2; exit 3; }
done
n=$(printf '%s' "$policies" | jq 'length'); i=0
while [ "$i" -lt "$n" ]; do
pid=$(printf '%s' "$policies" | jq -r --argjson i "$i" '.[$i].id')
st=$(policy_resources "$pid")
policies=$(printf '%s' "$policies" | jq -c --argjson i "$i" --argjson s "$st" '.[$i].appliedTo = $s')
i=$((i + 1))
done
printf '%s' "$policies" | jq -r '.[] | " \(.attributes.name // .id): status=\(.attributes.status // "unknown") realm=\(.attributes.rule.in // [] | join(",")) resources=\(.appliedTo | map(.applicationStatus) | group_by(.) | map("\(.[0])=\(length)") | join(","))"'
findings=$(printf '%s' "$policies" | jq -r --arg want "$EXPECTED_REALM" '
(if ([.[] | select(.attributes.status == "enabled")] | length) == 0
then "no enabled data-residency policy: no product is pinned to a realm" else empty end),
(if $want == "" then empty else
(.[] | select(.attributes.status == "enabled")
| select((.attributes.rule.in // []) | map(ascii_downcase) | index($want | ascii_downcase) | not)
| "\(.attributes.name // .id): realm \(.attributes.rule.in // [] | join(",")) is not \($want)") end),
(.[] | . as $p | .appliedTo[] | select(.applicationStatus == "failed")
| "\($p.attributes.name // $p.id): realm move failed on resource \(.id)")')
echo "data-residency policies: ${n}${EXPECTED_REALM:+; expected realm: ${EXPECTED_REALM}}"
if [ -n "$findings" ]; then
echo "FINDING:"; printf '%s\n' "$findings" | sed 's/^/ /'; exit 1
fi
exit 0
4.2 Implement Data Classification
Profile Level: L2 (Walk) NIST 800-53: AC-3
Description
Use data classification to restrict access to sensitive content.
Rationale
Why This Matters:
- Without classification, all content is treated the same and sensitive material is protected no better than routine notes
- Labeling spaces and projects by sensitivity drives access decisions so confidential and restricted content is gated to the right audiences
- Classification gives users a clear signal not to paste secrets or regulated data into low-sensitivity, broadly readable spaces
- Consistent labels enable automated policy and DLP enforcement and provide auditable evidence of how sensitive data is handled
Attack Prevented: Sensitive data exposure, over-sharing, mishandling of regulated content
ClickOps Implementation
Step 1: Enable Classification (Atlassian Guard Premium)
- Navigate to: admin.atlassian.com → Data classification
- Create classification levels:
- Public
- Internal
- Confidential
- Restricted
Step 2: Apply to Spaces/Projects
- Classify Confluence spaces
- Classify Jira projects
- Configure access based on classification
Code Implementation
Classification levels (Step 1) are defined for the organization, in the console or through the Data Loss Prevention REST API, which Atlassian marks experimental (Data Loss Prevention REST API, 2026-09-24). This guide ships no write pack for it. The pack audits Step 2 for Confluence through the Confluence REST API: it confirms the site has a published level and flags every current space with no default classification level. It does not cover Jira projects.
Code Pack: API Script
# $1 = path+query under /wiki/api/v2. Prints "<http_code>\n<body>"; transport failure -> 2.
site_get() {
local resp
resp=$(curl -sS --max-time 60 -w '\n%{http_code}' \
-H @<(printf 'Authorization: Basic %s\n' "$(printf '%s:%s' "$ATLASSIAN_EMAIL" "$ATLASSIAN_API_TOKEN" | base64 | tr -d '\n')") \
-H 'Accept: application/json' "${ATLASSIAN_SITE_URL}/wiki/api/v2$1") || { echo "ERROR: GET ${1%%\?*} failed in transport" >&2; return 2; }
printf '%s\n%s\n' "${resp##*$'\n'}" "${resp%$'\n'*}"
}
ok_body() { # $1 = site_get output, $2 = path for the message; body on 200, else return 2
local code="${1%%$'\n'*}"
[ "$code" = "200" ] || { echo "ERROR: GET ${2%%\?*} -> HTTP ${code}" >&2; return 2; }
printf '%s\n' "${1#*$'\n'}"
}
rc=0
out=$(site_get "/classification-levels"); levels=$(ok_body "$out" "/classification-levels")
published=$(printf '%s' "$levels" | jq -e -r 'if type == "array" then map(select(.status == "PUBLISHED") | .name) | join(", ") else error("not an array") end')
if [ -z "$published" ]; then echo "FINDING: no PUBLISHED classification level on this site"; rc=1
else echo "published classification levels: ${published}"; fi
spaces=''; cursor=''; pages=0
while :; do
out=$(site_get "/spaces?status=current&limit=250${cursor:+&cursor=${cursor}}"); page=$(ok_body "$out" "/spaces")
# A page without a results array stops the audit (jq error); it never shortens the space list.
spaces="${spaces}$(printf '%s' "$page" | jq -r 'if (.results | type) == "array" then .results[] | "\(.id) \(.key)" else error("no results array") end')"$'\n'
next=$(printf '%s' "$page" | jq -r '._links.next // empty')
[ -z "$next" ] && break
cursor=$(printf '%s' "$next" | sed -n 's/.*[?&]cursor=\([^&]*\).*/\1/p')
[ -n "$cursor" ] || { echo "ERROR: _links.next carries no cursor; space list is partial" >&2; exit 3; }
pages=$((pages + 1)); [ "$pages" -lt "$MAX_PAGES" ] || { echo "ERROR: MAX_PAGES reached; space list is partial" >&2; exit 3; }
done
n_spaces=$(printf '%s' "$spaces" | grep -c . || true)
[ "$n_spaces" -gt 0 ] || { echo "ERROR: 0 current spaces visible to this account; nothing was audited" >&2; exit 2; }
unclassified=''
while read -r sid skey; do
[ -n "$sid" ] || continue
out=$(site_get "/spaces/${sid}/classification-level/default")
case "${out%%$'\n'*}" in
200) ;;
404) unclassified="${unclassified} ${skey}"$'\n' ;;
*) echo "ERROR: GET /spaces/${sid}/classification-level/default -> HTTP ${out%%$'\n'*}" >&2; exit 2 ;;
esac
done < <(printf '%s' "$spaces")
echo "current spaces checked: ${n_spaces}"
if [ -n "$unclassified" ]; then
echo "FINDING: spaces with no default classification level (404: none applied, no entitlement, or not visible):"
printf '%s' "$unclassified"; rc=1
fi
exit "$rc"
4.3 Enforce Data Security Policies
Profile Level: L2 (Walk)
| Framework | Control ID |
|---|---|
| CIS Controls | 3.3, 3.12, 14.4 |
| NIST 800-53 | AC-3, AC-4, AC-21, SC-7(10) |
| ISO 27001:2022 | A.5.14, A.8.12 |
Description
Data security policies are organization-level rules that constrain what can be done with content in covered Jira projects and Confluence spaces, regardless of the permissions individual users hold. The organization’s policy holds one control per action: block content export, block attachment downloads, block Confluence public link sharing, restrict anonymous and external access, restrict which apps may reach covered content (the same Marketplace and custom app access control also manages third-party spreadsheet app permissions for project data), and block the Atlassian MCP server from reading covered content on behalf of AI agents. Each control has a default configuration of Blocked or Allowed for the whole organization, plus overrides for specific spaces and projects, apps, or (with Guard Premium) a data classification level; the attachment download and Atlassian MCP server controls need Atlassian Guard Standard or higher. Controls are configured at admin.atlassian.com → Security → Data protection → Data security policy. Sources: What is a data security policy?, Apply a default configuration to a policy control
Rationale
Why This Matters:
- Permission schemes control who can read content, but they do not control what a legitimate reader can then do with it — export, public link, or app-mediated extraction all remain available to anyone with view access
- Blocking export closes the single highest-volume exfiltration path, turning a one-click full-space download into a page-by-page manual effort that is slow, noisy, and visible in audit logs
- Confluence public links bypass authentication entirely, and a single link shared from an internal space silently publishes that page to anyone who receives the URL — including anyone who finds it indexed
- Restricting app access by space, project, or classification means a compromised or over-scoped Marketplace app cannot reach your most sensitive content even though it holds an org-wide grant, which directly limits Marketplace supply-chain blast radius
- Binding policies to classification levels rather than to individual spaces makes the control scale: newly created content inherits protection from its label instead of waiting for an admin to remember to add it
- Policies are enforced organization-wide and cannot be overridden by a space or project administrator, so they hold even when local permissions drift
- An AI agent reading through the Atlassian MCP server sees everything the connecting user can see; the MCP server control is what keeps covered content out of that agent’s reach while the user keeps direct access
Attack Prevented: Bulk data exfiltration via export, unauthenticated disclosure through public links, third-party app data harvesting, AI-agent data harvesting through the Atlassian MCP server, insider mass download, over-sharing with external collaborators
ClickOps Implementation
Step 1: Open the Organization’s Data Security Policy
- Navigate to: admin.atlassian.com → Security → Data protection → Data security policy (select your organization first if you have more than one)
- Read the control table: each control shows its Default configuration (Blocked or Allowed), the apps it Applies to, and its Overrides. Every control starts as Allowed for the whole organization
- To change a control, select it, select Edit to open a draft, then set the Blocked/Allowed dropdown or add overrides for specific spaces and projects, apps, or (with Guard Premium) a classification level so coverage follows the label. Review the draft and activate it
- Record a control’s overrides before changing its default configuration: changing the default removes the existing overrides
Step 2: Select the Rules
- Block export: prevents downloading covered content as PDF, Word, CSV, XML, or via bulk space export
- Block public links: prevents Confluence pages in scope from being shared through anonymous, unauthenticated URLs
- Restrict anonymous and external access: blocks anonymous viewing and limits guest or external collaborator reach into covered spaces
- Restrict app access: choose whether covered content is reachable by all apps, by an approved subset, or by none — this is the control that contains a compromised Marketplace app
- Manage third-party spreadsheet app permissions: control whether third-party spreadsheet apps can access project data in covered Jira projects. This is set through the app access control in item 4, because Block export does not govern third-party apps such as Google Sheets and Microsoft Excel (Manage third-party spreadsheet app permissions for project data)
- Prevent attachment downloads (Guard Standard and up; classification-level overrides need Guard Premium): blocks the download buttons for Jira and Confluence attachments and attachment downloads through the APIs. Users can still view attachments, and the control does not stop browser print, save, or extension-driven downloads (Prevent attachment downloads)
- Prevent Atlassian MCP server access (Guard Standard and up; classification-level overrides need Guard Premium): stops AI agents from reading covered Jira work items and Confluence content through the Atlassian MCP server, even when the connecting user can view that content directly. It covers only the Atlassian MCP server, not custom MCP servers, and data security policies are enforced only for OAuth connections, not for API token authentication (Prevent Atlassian MCP server access)
Step 3: Stage the Rollout
- Start with the highest-sensitivity spaces and projects — security, legal, HR, finance, and anything holding regulated data
- Communicate the change before enforcing; export blocking will visibly break existing reporting and offboarding workflows
- Identify legitimate export use cases and route them to an approved alternative such as a scoped API integration rather than granting broad exemptions
- Extend coverage outward once the first overrides are stable, and prefer classification-level overrides so new content is covered automatically
Step 4: Review on a Cadence
- Re-review each control’s default configuration and overrides quarterly against the current space and project inventory
- Confirm no high-sensitivity area was created outside every blocking override
- Re-verify the app allowance list after each new app approval
Code Implementation
The Admin Control REST API can create, update and publish data security policies (Admin Control REST API, 2026-09-24); this guide ships only the read side. The pack flags an organization with no active data security policy, an active policy that covers nothing, and any covered resource where the policy failed to apply. The API documents only the export rule, so confirm the other rules of Step 2 in the console.
Code Pack: API Script
api_get() { # key in a process-substitution header file, never in argv; non-200 -> 2
local resp code
resp=$(curl -sS --max-time 60 -w '\n%{http_code}' \
-H @<(printf 'Authorization: Bearer %s\n' "$ATLASSIAN_ORG_API_KEY") \
-H 'Accept: application/json' "${API}$1") || { echo "ERROR: GET ${1%%\?*} failed in transport" >&2; return 2; }
code=${resp##*$'\n'}
[ "$code" = "200" ] || { echo "ERROR: GET ${1%%\?*} -> HTTP ${code} (403 = access forbidden: check the key carries read:policies:admin)" >&2; return 2; }
printf '%s\n' "${resp%$'\n'*}"
}
BASE="/admin/control/v1/orgs/${ORG_ID}/policies"
# Resources of one policy. Models.PolicyResourcePage is paged (meta.next, links.next),
# but this GET documents no cursor parameter, so a next page is fetched only through
# the links.next URL the API returns, and only on this API base (the key never leaves
# it). A next page that cannot be followed makes the list partial (exit 3): a failed
# application on an unread page must never read as "no finding".
policy_resources() { # $1 = policy id; prints a JSON array of {id, applicationStatus}
local path="${BASE}/$1/resources" acc='[]' pg nx mn p=0
while :; do
pg=$(api_get "$path") || return 2
acc=$(printf '%s' "$pg" | jq -ce --argjson acc "$acc" 'if (.data | type) == "array" then $acc + [.data[] | {id, applicationStatus}] else error("no data array") end') || return 5
nx=$(printf '%s' "$pg" | jq -r '.links.next // empty') || return 5
mn=$(printf '%s' "$pg" | jq -r '.meta.next // empty') || return 5
if [ -z "$nx" ] && [ -z "$mn" ]; then printf '%s\n' "$acc"; return 0; fi
case "$nx" in
"${API}"/admin/control/*) path="${nx#"$API"}" ;;
/admin/control/*) path="$nx" ;;
*) echo "ERROR: resources of policy $1 continue on a page this pack cannot follow; list is partial" >&2; return 3 ;;
esac
p=$((p + 1)); [ "$p" -lt "$MAX_PAGES" ] || { echo "ERROR: MAX_PAGES reached; resources of policy $1 are partial" >&2; return 3; }
done
}
policies='[]'; cursor=''; pages=0
while :; do
page=$(api_get "${BASE}?type=data-security${cursor:+&cursor=$(jq -rn --arg v "$cursor" '$v|@uri')}")
policies=$(printf '%s' "$page" | jq -ce --argjson acc "$policies" 'if (.data | type) == "array" then $acc + .data else error("no data array") end')
cursor=$(printf '%s' "$page" | jq -r '.meta.next // empty')
[ -z "$cursor" ] && break
pages=$((pages + 1)); [ "$pages" -lt "$MAX_PAGES" ] || { echo "ERROR: MAX_PAGES reached; policy list is partial" >&2; exit 3; }
done
n=$(printf '%s' "$policies" | jq 'length'); i=0
while [ "$i" -lt "$n" ]; do
pid=$(printf '%s' "$policies" | jq -r --argjson i "$i" '.[$i].id')
st=$(policy_resources "$pid")
policies=$(printf '%s' "$policies" | jq -c --argjson i "$i" --argjson s "$st" '.[$i].appliedTo = $s')
i=$((i + 1))
done
printf '%s' "$policies" | jq -r '.[] | " \(.attributes.name // .id): status=\(.attributes.status // "unknown") coverage=\(.attributes.metadata.policyCoverageLevel // "unknown") export=\(.attributes.rule.export.effect // "unset") resources=\(.appliedTo | map(.applicationStatus) | group_by(.) | map("\(.[0])=\(length)") | join(","))"'
findings=$(printf '%s' "$policies" | jq -r '
(if ([.[] | select(.attributes.status == "active")] | length) == 0
then "no active data-security policy: export, public links and app access are unrestricted" else empty end),
(.[] | select(.attributes.status == "active" and .attributes.metadata.policyCoverageLevel == "UNASSIGNED")
| "\(.attributes.name // .id): active but covers nothing (coverage UNASSIGNED)"),
(.[] | . as $p | .appliedTo[] | select(.applicationStatus == "failed")
| "\($p.attributes.name // $p.id): application failed on resource \(.id)")')
echo "data-security policies: ${n}"
if [ -n "$findings" ]; then
echo "FINDING:"; printf '%s\n' "$findings" | sed 's/^/ /'; exit 1
fi
exit 0
Validation & Testing
- As a user with full view permission on a covered space, attempt a PDF and a space export and confirm both are refused
- As the same user, attempt to download an attachment on a covered page or work item from the file preview, from the attachments list, and through the REST API; confirm each download is refused while the attachment still displays
- Attempt to create a public link on a covered Confluence page and confirm the option is unavailable or blocked
- Attempt to access a covered space anonymously in a logged-out browser session and confirm access is refused
- Using an app that is not on the allowance list, attempt to read covered content through its integration and confirm the request fails
- Open a covered Jira project’s issues in a third-party spreadsheet app such as Google Sheets or Microsoft Excel and confirm no issues from that project are returned (the “Open in” menu option still appears; that is expected)
- Connect an AI tool to the Atlassian MCP server with OAuth as a user who can view a covered page and work item, ask it for both, and confirm the agent gets a “does not exist or you don’t have permission” response and that covered work items are absent from its JQL search results
- Repeat the export test on an uncovered space to confirm the policy is scoped as intended and has not silently blocked more than planned
- If overrides are set by classification level, apply the classification label to a new test space and confirm the restrictions take effect without any further admin action
- Review the audit log to confirm policy creation and modification events are recorded and attributable
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC6.1 | Logical access controls over protected information |
| SOC 2 | CC6.7 | Restrictions on transmission and movement of information |
| NIST 800-53 | AC-4 | Information flow enforcement |
| NIST 800-53 | AC-21 | Information sharing restrictions |
| ISO 27001:2022 | A.5.14 | Information transfer controls |
| ISO 27001:2022 | A.8.12 | Data leakage prevention |
| CIS Controls | 3.3 | Configure data access control lists |
5. Monitoring & Detection
5.1 Enable Audit Logging
Profile Level: L1 (Crawl) NIST 800-53: AU-2, AU-3
Description
Configure and monitor Atlassian audit logs.
Rationale
Why This Matters:
- Audit logs are the primary record of authentication, permission changes, app installs, and data exports needed to detect and investigate abuse
- Without centralized logging, malicious activity such as a quiet permission escalation or bulk export goes unnoticed until damage is done
- Streaming logs to a SIEM preserves evidence beyond the platform’s retention window and correlates Atlassian events with the rest of the environment
- Reliable audit trails are required for incident response and for SOC 2, ISO 27001, and similar compliance attestations
Attack Prevented: Undetected intrusion, repudiation, delayed incident response, tampering concealment
ClickOps Implementation
Step 1: Access Audit Logs
- Navigate to: admin.atlassian.com → Security → Audit log
- Review events:
- Authentication events
- Permission changes
- App installations
- Data exports
Step 2: Configure SIEM Export
- Navigate to: Settings → Audit log streaming (SIEM webhook forwarding requires Atlassian Guard Premium)
- Configure destination:
- Splunk
- Sumo Logic
- Custom webhook
Step 3: Correlate with Product-Level Logs
- The organization audit log records identity, policy, app, and product-access events across the whole tenant — it is the authoritative record for the controls in this hub
- Individual products keep their own narrower logs (the Bitbucket workspace audit log, the Jira automation audit log) that record activity the organization log does not. Forward those alongside the organization log rather than in place of it — see the Bitbucket guide §5.1 and the Jira Cloud guide §3.1
- Confirm your SIEM retention exceeds each source’s native retention, since product-level logs expire far sooner than an investigation timeline
Code Implementation
Audit log streaming configuration has no REST resource (Cloud admin REST APIs, 2026-09-24), so Step 2 stays ClickOps. The pack is the pull alternative: it exports organization audit events as one JSON object per line for a SIEM collector, and prints a cursor to resume from on the next run.
Code Pack: API Script
api_get() { # key in a process-substitution header file, never in argv; non-200 -> 2
local resp code
resp=$(curl -sS --max-time 60 -w '\n%{http_code}' \
-H @<(printf 'Authorization: Bearer %s\n' "$ATLASSIAN_ORG_API_KEY") \
-H 'Accept: application/json' "${API}$1") || { echo "ERROR: GET ${1%%\?*} failed in transport" >&2; return 2; }
code=${resp##*$'\n'}
[ "$code" = "200" ] || { echo "ERROR: GET ${1%%\?*} -> HTTP ${code} (check the key is valid and carries read:events:admin)" >&2; return 2; }
printf '%s\n' "${resp%$'\n'*}"
}
from_ms=$(( ( $(date +%s) - LOOKBACK_HOURS * 3600 ) * 1000 ))
query="from=${from_ms}&limit=500&sortOrder=asc${CURSOR:+&cursor=$(jq -rn --arg c "$CURSOR" '$c|@uri')}"
pages=0; total=0; last=''
while :; do
page=$(api_get "/admin/v1/orgs/${ORG_ID}/events-stream?${query}")
n_rows=$(printf '%s' "$page" | jq -e '.data | if type == "array" then length else error("no data array") end')
[ "$n_rows" -eq 0 ] || printf '%s' "$page" | jq -c '.data[]'
total=$(( total + n_rows )); pages=$(( pages + 1 ))
next=$(printf '%s' "$page" | jq -r '.meta.next // empty')
[ -z "$next" ] || last="$next"
# events-stream is a poll: it returns a cursor even at the end, so an empty page ends the walk.
if [ -z "$next" ] || [ "$n_rows" -eq 0 ]; then break; fi
if [ "$pages" -ge "$MAX_PAGES" ]; then
echo "exported ${total} events in ${pages} pages; stopped at MAX_PAGES" >&2
echo "ERROR: partial export. Resume with CURSOR=${last}" >&2
exit 3
fi
query="from=${from_ms}&limit=500&sortOrder=asc&cursor=$(jq -rn --arg c "$next" '$c|@uri')"
sleep "$PAGE_DELAY"
done
echo "exported ${total} events in ${pages} pages (since ${LOOKBACK_HOURS}h ago)" >&2
[ -z "$last" ] || echo "next poll: CURSOR=${last}" >&2
5.2 Configure Anomaly Detection
Profile Level: L2 (Walk)
Description
Enable anomalous activity detection, which is delivered by Atlassian Guard Premium (the product formerly sold as Atlassian Access). It is a Guard subscription capability rather than a Jira or Confluence product-edition feature, so a Premium or Enterprise product plan alone does not enable it. Source: Atlassian Guard pricing
Rationale
Why This Matters:
- Anomaly detection flags behavior that static rules miss, such as impossible-travel logins or atypical bulk data access
- Account takeover and insider abuse often look like ordinary activity until viewed against the user’s established baseline
- Automated alerts shrink dwell time by surfacing suspicious events in near real time rather than during a periodic log review
- Early warning on permission changes catches an attacker escalating privileges before they reach sensitive projects
Attack Prevented: Account takeover, insider abuse, credential stuffing, stealthy privilege escalation
ClickOps Implementation
- Navigate to: admin.atlassian.com → Security → Anomaly detection
- Enable detection for:
- Unusual login locations
- Bulk data access
- Permission changes
- Configure alert recipients
Code Implementation
Turning detections on has no REST resource (Cloud admin REST APIs, 2026-09-24), so the steps above stay ClickOps. Detection output is readable: the pack counts Guard Detect events in the look-back window and fails on any, so each one gets triaged. Atlassian’s reference does not say whether an organization without Guard Premium detections gets an error or an empty list, so a window with no events is reported as inconclusive. The pack reports “no alerts” only after you confirm on the Anomaly detection page above that detection is on, and re-run it with GUARD_PREMIUM=confirmed.
Code Pack: API Script
api_get() { # key in a process-substitution header file, never in argv; non-200 -> 2
local resp code
resp=$(curl -sS --max-time 60 -w '\n%{http_code}' \
-H @<(printf 'Authorization: Bearer %s\n' "$ATLASSIAN_ORG_API_KEY") \
-H 'Accept: application/json' "${API}$1") || { echo "ERROR: GET ${1%%\?*} failed in transport" >&2; return 2; }
code=${resp##*$'\n'}
[ "$code" = "200" ] || { echo "ERROR: GET ${1%%\?*} -> HTTP ${code} (check the key is valid and carries read:events:admin)" >&2; return 2; }
printf '%s\n' "${resp%$'\n'*}"
}
from_ms=$(( ( $(date +%s) - LOOKBACK_DAYS * 86400 ) * 1000 ))
events='[]'; cursor=''; pages=0
while :; do
page=$(api_get "/admin/v1/orgs/${ORG_ID}/events?product=guard_detect&from=${from_ms}&limit=500${cursor:+&cursor=$(jq -rn --arg c "$cursor" '$c|@uri')}")
events=$(printf '%s' "$page" | jq -ce --argjson acc "$events" 'if (.data | type) == "array" then $acc + .data else error("no data array") end')
cursor=$(printf '%s' "$page" | jq -r '.meta.next // empty')
[ -z "$cursor" ] && break
pages=$((pages + 1)); [ "$pages" -lt "$MAX_PAGES" ] || { echo "ERROR: MAX_PAGES reached; alert list is partial" >&2; exit 3; }
sleep "$PAGE_DELAY"
done
n=$(printf '%s' "$events" | jq 'length')
echo "guard_detect events in the last ${LOOKBACK_DAYS} days: ${n}"
if [ "$n" -gt 0 ]; then
printf '%s' "$events" | jq -r 'group_by(.attributes.action) | .[] | " \(.[0].attributes.action): \(length) (latest \(map(.attributes.time) | max))"'
echo "FINDING: triage these detections in admin.atlassian.com, then record the outcome"
exit 1
fi
# The API returns the same empty list whether or not detection is running.
if [ "$GUARD_PREMIUM" != "confirmed" ]; then
echo "INCONCLUSIVE: no guard_detect events, and nothing here shows detection is active." >&2
echo " Confirm detection is on (admin.atlassian.com > Security > Anomaly detection), then re-run with GUARD_PREMIUM=confirmed." >&2
exit 4
fi
echo "no alerts in window (Guard Premium detections confirmed active by the operator)"
exit 0
6. Vulnerability Management
6.1 Critical CVE Response
Profile Level: L1 (Crawl)
Description
Recent critical vulnerabilities require immediate attention.
Rationale
Why This Matters:
- Critical Confluence and Jira vulnerabilities have included broken access control and OGNL injection enabling unauthenticated remote code execution and rogue admin account creation
- These flaws are weaponized and actively exploited within days of disclosure, so unpatched Data Center and Server instances are high-value targets
- Prompt patching plus log review for exploitation attempts and unauthorized admin accounts limits both initial compromise and attacker persistence
- A defined CVE response process ensures advisories are acted on immediately instead of waiting for the next maintenance window
Attack Prevented: Remote code execution, authentication bypass, unauthorized admin creation, data destruction
Recent Critical CVEs
| CVE | CVSS | Product | Description |
|---|---|---|---|
| CVE-2023-22515 | 10.0 | Confluence DC/Server | Broken access control, admin account creation |
| CVE-2023-22518 | 9.8 | Confluence DC/Server | Auth bypass, data destruction |
| CVE-2022-26134 | 9.8 | Confluence Server/DC | OGNL injection RCE |
| CVE-2021-26084 | 9.8 | Confluence Server/DC | OGNL injection RCE |
ClickOps Implementation
For Atlassian Cloud (Atlassian patches the platform):
- Watch Atlassian security advisories. Atlassian notifies customers whenever a critical security vulnerability is discovered and resolved
- When an advisory lands, navigate to: admin.atlassian.com → Security → Audit log (the §5.1 path) and review permission changes and app installations for the advisory window
For Data Center/Server:
- Apply the fixed version named in the advisory, or its interim mitigation until you can upgrade (for CVE-2023-22515, block access to the
/setup/*endpoints) - Check for the advisory’s evidence of compromise. For CVE-2023-22515 that is: unexpected members of the
confluence-administratorsgroup, unexpected newly created user accounts, installed unknown plugins, requests to/setup/*.actionin network access logs, and/setup/setupadministrator.actionin an exception message inatlassian-confluence-security.login the Confluence home directory
Response Actions
For Data Center/Server:
- Apply patches immediately
- Review access logs for exploitation attempts
- Check for unauthorized admin accounts
- Consider migration to Cloud
For Cloud:
- Atlassian manages patching
- Monitor Atlassian security advisories
- Review audit logs for suspicious activity
Automation: ClickOps only — Atlassian Cloud patching is vendor-operated and Data Center patching is a product upgrade; Atlassian exposes no write interface for this setting (Security advisories, 2026-09-24).
7. Compliance Quick Reference
SOC 2 Mapping
| Control ID | Atlassian Control | Guide Section |
|---|---|---|
| CC6.1 | SSO enforcement, domain verification, 2SV | 1.1 |
| CC6.2 | Product access and organization admin roles | 1.2 |
| CC6.3 | SCIM/JIT provisioning and deprovisioning | 1.1 |
| CC6.6 | IP allowlisting | 1.4 |
| CC6.7 | Data security policies | 4.3 |
| CC7.2 | Audit logging | 5.1 |
NIST 800-53 Mapping
| Control | Atlassian Control | Guide Section |
|---|---|---|
| IA-2(1) | SSO with MFA | 1.1 |
| AC-2 | SCIM/JIT provisioning and deprovisioning | 1.1 |
| AC-6(1) | Organization admin role limitation | 1.2 |
| AC-17 | IP allowlisting | 1.4 |
| CM-7 | App approval workflow | 2.1 |
| AU-2 | Audit logging | 5.1 |
Appendix A: Edition Compatibility
Atlassian licensing splits across two independent axes, and conflating them is a common planning error. The product edition (Free, Standard, Premium, Enterprise) governs Jira and Confluence features. Atlassian Guard — formerly Atlassian Access — is a separate per-user subscription that governs identity and data-security capabilities across the whole organization. Buying Jira Premium does not grant Guard capabilities, and buying Guard does not grant product-edition features.
Product Edition Capabilities
| Control | Free | Standard | Premium | Enterprise |
|---|---|---|---|---|
| MFA (individual two-step verification) | ✅ | ✅ | ✅ | ✅ |
| Project/space permission schemes | ✅ | ✅ | ✅ | ✅ |
| Basic product audit log | Basic | Basic | ✅ | ✅ |
| IP allowlisting (1.4) | ❌ | ❌ | ✅ | ✅ |
| Data residency (4.1) | ❌ | ✅ | ✅ | ✅ |
Atlassian Guard Capabilities
| Control | No Guard | Guard Standard | Guard Premium |
|---|---|---|---|
| SAML SSO enforcement (1.1) | ❌ | ✅ | ✅ |
| Enforced authentication policies (1.1) | ❌ | ✅ | ✅ |
| SCIM user provisioning and deprovisioning | ❌ | ✅ | ✅ |
| Mobile application management (MAM) policies | ❌ | ✅ | ✅ |
| API token controls (1.3) | ❌ | ✅ | ✅ |
| Organization audit log (5.1) | ❌ | ✅ | ✅ |
| Data classification (4.2) | ❌ | ❌ | ✅ |
| Data security policies (4.3) | App access control only | All controls, no classification-level overrides | ✅ |
| Anomalous activity detection (5.2) | ❌ | ❌ | ✅ |
| Content scanning | ❌ | ❌ | ✅ |
| SIEM webhook forwarding (5.1) | ❌ | ❌ | ✅ |
Source: Atlassian Guard pricing; data security policy row: the availability table in What is a data security policy?
Appendix B: References
Official Atlassian Documentation:
-
Trust Center Customer Trust Center (powered by Conveyor) - Atlassian Support
- Security Best Practices
- Security Practices
- Security Measures
- Vulnerability Disclosure
- Security Advisories
- Atlassian Guard Pricing and Capabilities (product formerly named Atlassian Access)
- Manage API Tokens for Your Atlassian Account (mandatory token expiry, 1 day to 1 year)
- What Is a Data Security Policy?
- Scope of IP Allowlists in Atlassian Cloud
API & Developer Tools:
- Atlassian Developer Portal
- Jira Cloud REST API
- Confluence Cloud REST API
- Forge App Framework (SOC 2 and ISO 27001 compliant; preferred over Connect)
- Connect End-of-Support Timeline (descriptor freeze March 2026, end-of-support enforcement Q4 2026)
- Security for Connect Apps
- Cloud Admin REST APIs (index of the six admin APIs used by this guide’s packs and automation verdicts)
- Organizations REST API (domains, directory users, audit log events)
- Admin Control REST API (IP allowlist, data residency and data security policies)
- API Access REST API (organization-wide API token inventory)
- GitHub Organization
Compliance Frameworks:
- SOC 2 Type II (individual product audits on a regular basis) — via SOC 2 Compliance
- ISO/IEC 27001:2022 (Atlassian Trust Management System) — via ISO 27001 Compliance
- SOX compliance, PCI DSS
-
Compliance Resource Center Compliance FAQ
Security Incidents:
- 2023 — Critical Confluence CVEs: CVE-2023-22515 (CVSS 10.0) and CVE-2023-22518 (CVSS 9.8) affected Confluence Data Center/Server with broken access control and auth bypass vulnerabilities. Actively exploited in the wild. Cloud instances were not affected. (Security Advisories)
- February 2023 — Employee Data Leak via Envoy: Hackers leaked Atlassian employee records and office floorplans obtained through a breach of third-party workplace platform Envoy. No customer data was affected. (SecurityWeek Report)
Changelog
| Date | Version | Maturity | Changes | Author |
|---|---|---|---|---|
| 2026-09-25 | 0.5.0 | ai-drafted | validate-hth-guide fix loop, offline: the Atlassian console was behind a sign-in wall, so 0 surfaces were exercised live and maturity is unchanged. Add read-only API packs for 1.1, 1.2, 1.3, 1.4, 3.1, 3.2, 4.1, 4.2, 4.3, 5.1 and 5.2; replace the 2.2 pack, which called an undocumented audit-events resource and exited 0 on HTTP 401. Add **Automation:** ClickOps only verdicts to 2.1, 3.3 and 6.1, and ClickOps sections to 2.2 and 6.1 (6.1: the advisories page, the Cloud audit-log path, and CVE-2023-22515’s interim mitigation and evidence-of-compromise checks for Data Center). Correct the legacy API-token expiry sequence (1.3), the Connect end-of-support date to Q4 2026 (2.1), the data residency plan gate to Standard and up (4.1), and the Jira webhooks path (3.2). Bring 4.3 in line with Atlassian’s current data security policy docs: the console path (Security → Data protection → Data security policy), per-control default configurations with overrides, and the three rules the guide lacked (third-party spreadsheet app permissions, attachment downloads and Atlassian MCP server access, the last two Guard Standard and up), each with a validation test; correct the Appendix A data security policy row, since the app access control needs no Guard. Remove an unsourced mobile-traffic claim (1.4), rename mobile app policies to Mobile application management (MAM), and repoint two dead Appendix B links |
Claude Code (Opus 5.5) |
| 2026-08-08 | 0.4.0 | ai-drafted | Convert to the Atlassian platform Common Controls hub: add platform/platform_slug/product frontmatter, add “Products in This Platform” section and moved-controls callout. Merge org-level controls in from the product guides — domain verification, SSO enforcement policy with break-glass, and SCIM/JIT provisioning into §1.1; organization admin role limitation into §1.2. Point Jira project permissions at the Jira Cloud guide and note product-level log correlation in §5.1; remove empty Detection Queries heading |
Claude Code (Opus 5) |
| 2026-08-03 | 0.3.0 | ai-drafted | Rename Atlassian Access to Atlassian Guard and split edition table into product vs Guard tiers; correct API token expiry to the 1-day-to-1-year platform ceiling with forced expiry of legacy tokens; add 1.4 IP allowlisting; add 4.3 data security policies; flag Connect framework end-of-support and prefer Forge | Claude Code (Sonnet 5) |
| 2026-06-29 | 0.2.1 | ai-drafted | Add cheat-sheet Description and Rationale for all controls | Claude Code (Opus 4.8) |
| 2025-12-14 | 0.1.0 | ai-drafted | Initial Atlassian hardening guide | Claude Code (Opus 4.5) |
| 2026-02-19 | 0.2.0 | ai-drafted | Extract inline code to Code Packs (api, sdk) | Claude Code (Opus 4.6) |
Contributing
Found an issue or want to improve this guide?
- Report outdated information: Open an issue with tag
content-outdated - Propose new controls: Open an issue with tag
new-control - Submit improvements: See Contributing Guide