Forums

Articles
Create
cancel
Showing results forΒ 
Search instead forΒ 
Did you mean:Β 

A Guide to Service Accounts in Atlassian Cloud - Part 4: Token renewal

Some time has passed since Part 1, 2 and 3 of this series.

That also means that Service Accounts that were created a while ago may run into token expiration sooner rather than later. Thankfully, Atlassian has provided new tools that make managing Service Accounts a lot easier on a larger scale, so let's jump right into it.

 


Introducing: The API Access API

…gotta love Atlassian terminology and naming πŸ™‚ https://developer.atlassian.com/cloud/admin/api-access/rest/intro

The API Access API gives you primarily read access to API tokens and OAuth credentials in the organization. For Service Accounts, the interesting endpoints are currently:

  • List API tokens for one Service Account
  • Count API tokens for multiple Service Accounts
  • List all API tokens in the organization, if you want to include personal API tokens in the same report
πŸ’‘ These endpoints require an Organization API Key with certain scopes – after reading the article series you of course know how scopes work and where to find the required ones πŸ™‚ πŸ’‘

 

Important limitation: As I am writing this post, the Endpoints around Service Accounts and Tokens are still experimental and are blocked per default. Your Organization needs to be added to a whitelist through Support if you want to use them.

The API will help you find and revoke tokens. It does not let you create new Service Account tokens automatically. Token renewal still needs a manual step in the Admin UI.

 


Find expiring tokens

While Service Accounts can live for all eternity, Tokens have a maximum lifespan of 365 days. As an Org Admin, you will get an Email for Token expiration. Service Account Owners are not technically linked to their Service Accounts, so (worst case) their Automations and Integrations will fail silently – unless you act proactively.

πŸ’‘ We only need to worry about API Tokens, as OAuth Credentials don't have expiration dates πŸ’‘

So how to find those expiring tokens?

The idea is simple:

  1. Query all Service Accounts: https://developer.atlassian.com/cloud/admin/api-access/rest/api-group-service-account/#api-orgs-orgid-service-accounts-get
  2. For those Service Accounts, find all API Tokens: https://developer.atlassian.com/cloud/admin/api-access/rest/api-group-api-token/#api-orgs-orgid-service-accounts-accountid-api-tokens-get
  3. Check if the expiresAt Date is less than x days away
  4. List the results that we can use for the next step

Things to watch out for:

  • Parts of this API are still Experimental – you need to opt in via the Headers AND your organization needs to be whitelisted
  • There is API rate limiting
  • Results are paginated
  • Date formatting
  • When using this Script more than once, be sure to not store the Org Admin Key in the script

Information we need for the next step:

  • Service Account name and ID
  • Token label
  • Token status
  • Expiration date
  • Last active date
  • Direct Admin UI link for the renewal step
πŸ’‘ Do not wait until the last day. Tokens are credentials, and credential changes always have the potential to break something. Give yourself enough time to coordinate with the owning team. πŸ’‘

 

Thank god for Coding LLMs that grant me the ability to provide an example Python script for you to use and adjust πŸ™‚ The report will return expiring tokens per Service Account and also includes a link directly to the correct UI in the administration.

Example Script for finding expiring tokens

"""
Finds Atlassian Cloud Service Account API tokens that are expiring soon.

Usage:
    Set ORG_ID, API_KEY, MIN_DAYS_AWAY, and MAX_DAYS_AWAY below, then run:
        python ExpiringTokens.py
"""

import re
import time
from datetime import datetime, timezone

import requests

# --- CONFIGURATION ---
ORG_ID = "xxx"
API_KEY = "xxx"
MIN_DAYS_AWAY = 0
MAX_DAYS_AWAY = 30
# --- END CONFIGURATION ---

BASE_URL = "https://api.atlassian.com"
MAX_RETRIES = 5


def request_with_retry(url, headers):
    """GET request that automatically retries on 429 rate limiting."""
    for attempt in range(1, MAX_RETRIES + 1):
        response = requests.get(url, headers=headers)

        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            wait_time = int(retry_after) if retry_after and retry_after.isdigit() else 2 ** attempt
            print(f"Rate limited. Waiting {wait_time}s before retry ({attempt}/{MAX_RETRIES})...")
            time.sleep(wait_time)
            continue

        if response.status_code >= 400:
            print(f"Error {response.status_code} calling {url}")
            print(f"Response body: {response.text}")

        response.raise_for_status()
        return response

    raise RuntimeError(f"Gave up after {MAX_RETRIES} retries due to rate limiting: {url}")


def parse_next_link_header(link_header):
    """Parses an RFC 5988 Link header for a rel='next' URL, if present."""
    if not link_header:
        return None
    for part in link_header.split(","):
        match = re.match(r'\s*<([^>]+)>\s*;\s*rel="?next"?', part)
        if match:
            return match.group(1)
    return None


def paginated_get(url, headers):
    """
    Yields all items from an Atlassian admin API endpoint.
    Handles both response shapes seen in these APIs:
      - a bare JSON list (tokens endpoint)
      - an object with items/links (accounts endpoint)
    """
    while url:
        response = request_with_retry(url, headers)
        payload = response.json()

        if isinstance(payload, list):
            items = payload
            next_link = parse_next_link_header(response.headers.get("Link"))
        else:
            items = payload.get("items", payload.get("data", []))
            next_link = payload.get("links", {}).get("next")

        yield from items

        if not next_link:
            url = None
        elif next_link.startswith("http"):
            url = next_link
        else:
            url = BASE_URL + next_link


def parse_date(value):
    """Parses an ISO 8601 timestamp string into a timezone-aware datetime."""
    if not value:
        return None
    return datetime.fromisoformat(value.replace("Z", "+00:00"))


def main():
    if ORG_ID == "your_org_id_here" or API_KEY == "your_api_key_here":
        print("Error: Please set ORG_ID and API_KEY at the top of the script.")
        return

    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Accept": "application/json",
        "X-ExperimentalApi": "opt-in",
    }
    today = datetime.now(timezone.utc)
    found = 0

    print("Fetching service accounts...")
    accounts_url = f"{BASE_URL}/admin/api-access/v1/orgs/{ORG_ID}/service-accounts"

    for account in paginated_get(accounts_url, headers):
        account_id = account.get("id")
        account_name = account.get("displayName", "N/A")

        tokens_url = (
            f"{BASE_URL}/admin/api-access/v1/orgs/{ORG_ID}"
            f"/service-accounts/{account_id}/api-tokens"
        )

        try:
            tokens = list(paginated_get(tokens_url, headers))
        except requests.exceptions.HTTPError:
            continue

        for token in tokens:
            expires_at = parse_date(token.get("expiresAt"))
            if not expires_at:
                continue

            days_left = (expires_at - today).days
            if MIN_DAYS_AWAY <= days_left <= MAX_DAYS_AWAY:
                found += 1
                print("-" * 60)
                print(f"Service Account : {account_name} ({account_id})")
                print(f"Token Label     : {token.get('label', 'N/A')}")
                print(f"Token Status    : {token.get('status', 'N/A')}")
                print(f"Created         : {token.get('createdAt', 'N/A')}")
                print(f"Expires         : {token.get('expiresAt')} ({days_left} days left)")
                print(f"Last Active     : {token.get('lastActiveAt') or 'Never'}")
                print(
                    f"Renewal Link    : https://admin.atlassian.com/o/{ORG_ID}"
                    f"/service-accounts/{account_id}?tab=credentials"
                )

    print("-" * 60)
    print(f"Done. {found} token(s) expiring between {MIN_DAYS_AWAY} and {MAX_DAYS_AWAY} days.")


if __name__ == "__main__":
    main()

Renew expiring tokens

Once we have the overview of soon-to-expire tokens, we need to work on renewing those tokens. Unfortunately, this part is still not fully automatable. There is no public API for creating a new Service Account API token with scopes. So the renewal process still requires clicking in the UI.

πŸ’‘ Service Account Owners need time to exchange tokens in their scripts and integrations, so old and new tokens need to be active simultaneously for a while πŸ’‘

I'd recommend the following flow:

  1. Open the Service Account directly from your report: https://admin.atlassian.com/o/{orgId}/service-accounts/{serviceAccountId}?tab=credentials
  2. Find the expiring token in the Credentials tab.
  3. Open the Token by clicking on the Name
  4. Copy the Scope
  5. Create a new token.
  6. Paste the copied scopes into the scope selection search field and select the matching scopes.
  7. Copy the new token immediately and store it safely.
  8. Share the new token with the Service Account Owner
  9. Give them some time
  10. Revoke the old token after the new one is confirmed to work.

 


Find inactive Service Accounts

Token expiration is only one part of the story. The second question is: "Do we still need this Service Account at all?"

The API token response contains lastActiveAt. That means you can identify tokens that have not been used for a while and then review the owning Service Account. You can use the script from above or slightly adjust it to filter for lastActiveAt Dates instead of expiresAt.

Remember that there could be active OAuth Credentials lurking somewhere, so always double check with the Service Account Owner, before deleting anything. Revoking credentials could be a first step. Deleting the Service Account will also remove all its space permissions and it's tedious to restore all permissions if they needed the Account after all.

Before removing a Service Account:

  • Check token activity.
  • Check documentation and ownership.
  • Ask the owner or owning team for confirmation.
  • Revoke or rotate tokens first.
  • Wait a short grace period if the integration could be business-critical.
  • Delete the Account
  • Remove the account from documentation/assets or mark as deleted, once cleanup is complete

 


Conclusion

Atlassian has provided some new tooling to help us manage Service Accounts and their credentials. While parts remain manual, this also gives us the chance for a regular check if everything is still active and needed.

My recommendation: build a lightweight monthly review using the script as a little helper. Find tokens that expire soon, renew them with the owning team, and use the same report to identify accounts that may no longer be needed. If you have mandatory Service Requests for Service Account / Token creation, you could use the same channel for Token renewals, approvals for deletion etc.

πŸ’‘ Ideally, on Token creation, you're already setting the expiration dates on a specific day of the month (e.g. the 1st). That way, you can find Tokens that are about to expire at the beginning of the Month, Account owners will have enough time to update their scripts and integrations, and before the next expiration run, you can just remove all expired tokens. Make it a scheduled job once a month, rather than being triggered per expiring token πŸ’‘

I'm interested: How are you currently tracking Service Accounts and token renewals? Let me know in the comments and I'll add useful ideas to this article.

Note: I've updated relevant sections in Part 1, 2 and 3 to reflect these changes as well.

2 comments

Thorsten Letschert _Decadis AG_
Community Champion
August 13, 2026

Great guide!

Like β€’ Rebekka Heilmann _viadee_ likes this
Ben - ScriptRunner
I'm New Here
I'm New Here
Those new to the Atlassian Community have posted less than three times. Give them a warm welcome!
August 14, 2026

Fantastic guide! I always find it so frustrating when those pesky API tokens expire, so getting a preemptive heads-up is always appreciated!

I think something to watch out for with ad-hoc admin scripts is that they can introduce both operational and security risks. For example:

  • Security Surface Area: Keeping sensitive API tokens saved locally on laptops increases your risk if a machine is lost, stolen, or improperly wiped.

  • Lack of Visibility & Execution Gaps: A lack of a centralised store for the script means poor team visibility, and it can lead to missed executions if the script owner is on leave or out of the office.

As a software engineer for ScriptRunner for Jira Cloud, I can suggest one potential workaround for these issues is to use ScriptRunner to manage your script, tokens, and process centrally:

  • ScriptRunner Scheduled Jobs: Run your token audit on an automated schedule so your script will execute reliably, even if your admins are on leave.

  • Script Variables: Keep your admin API tokens securely stored within ScriptRunner rather than on local devices.

  • Automated Ticket Creation: Have the script automatically spin up a Jira issue for your admin team to rotate the secret, keeping the work trackable inside Jira.

I’ve put together an example Groovy script designed for ScriptRunner Cloud. It uses Atlassian’s API Tokens endpoint to retrieve tokens and filter specifically for service accounts:

/**
 * Finds Atlassian Cloud Service Account API tokens that are expiring soon and
 * raises a Jira issue for each one so the rotation work is tracked and visible.
 *
 * Runtime: ScriptRunner for Jira Cloud (Script Console / Scheduled Job).
 *
 * Service accounts are identified from the org-wide token list by their
 * @serviceaccount.atlassian.com email, rather than via the org's "list service
 * accounts" endpoint, which returned 404 (route not available for this org).
 *
 * Two different credential types are involved:
 *  - Calls to api.atlassian.com (org admin API) use ADMIN_API_KEY below, an
 *    Atlassian admin API key generated at admin.atlassian.com -> Settings -> API keys,
 *    with the read:tokens:admin scope.
 *  - Work item creation on this site uses HAPI (WorkItems.create), which is
 *    authenticated automatically - no credentials needed.
 */

import groovy.json.JsonSlurper
import groovy.transform.Field

import java.time.Duration
import java.time.LocalDate
import java.time.OffsetDateTime

// Bound variables injected via ScriptRunner Script Variables:
// String ORG_ID
// String ADMIN_API_KEY

// --- CONFIGURATION ---
@Field final int MIN_DAYS_AWAY = 0
@Field final int MAX_DAYS_AWAY = 30
@Field final String PROJECT_KEY = "YOUR_PROJECT_KEY"
@Field final String ISSUE_TYPE = "Task"
// --- END CONFIGURATION ---

@Field final String BASE_URL = "https://api.atlassian.com"
@Field final int MAX_RETRIES = 5

/** GET request against an external (api.atlassian.com) URL, retrying on 429. */
Map getWithRetry(String urlString) {
    for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
        def response = get(urlString)
            .header("Authorization", "Bearer ${ADMIN_API_KEY}")
            .header("Accept", "application/json")
            .header("X-ExperimentalApi", "opt-in")
            .asString()

        if (response.status == 429) {
            String retryAfter = response.headers.getFirst("Retry-After")
            int waitSeconds = retryAfter?.isInteger() ? retryAfter.toInteger() : (2 ** attempt) as int
            logger.info("Rate limited. Waiting ${waitSeconds}s before retry (${attempt}/${MAX_RETRIES}) for ${urlString}")
            Thread.sleep(waitSeconds * 1000L)
            continue
        }

        if (response.status >= 400) {
            throw new RuntimeException("Error ${response.status} calling ${urlString}: ${response.body}")
        }

        return [body: response.body, linkHeader: response.headers.getFirst("Link")]
    }
    throw new RuntimeException("Gave up after ${MAX_RETRIES} retries due to rate limiting: ${urlString}")
}

/** Parses an RFC 5988 Link header for a rel="next" URL, if present. */
String parseNextLinkHeader(String linkHeader) {
    if (!linkHeader) {
        return null
    }
    for (String part : linkHeader.split(",")) {
        def match = (part =~ /\s*<([^>]+)>\s*;\s*rel="?next"?/)
        if (match.find()) {
            return match.group(1)
        }
    }
    return null
}

/**
 * Returns all items from an Atlassian admin API endpoint.
 * Handles both response shapes seen in these APIs:
 *   - a bare JSON list (tokens endpoint)
 *   - an object with items/links (accounts endpoint)
 */
List<Map> paginatedGet(String startUrl) {
    List<Map> results = []
    String url = startUrl

    while (url) {
        Map response = getWithRetry(url)
        def payload = new JsonSlurper().parseText(response.body)

        String nextLink
        if (payload instanceof List) {
            results.addAll(payload)
            nextLink = parseNextLinkHeader(response.linkHeader)
        } else {
            results.addAll((payload.items ?: payload.data ?: []) as List<Map>)
            nextLink = payload.links?.next
        }

        url = !nextLink ? null : (nextLink.startsWith("http") ? nextLink : BASE_URL + nextLink)
    }
    return results
}

/** Parses an ISO 8601 timestamp string into an OffsetDateTime. */
OffsetDateTime parseDate(String value) {
    if (!value) {
        return null
    }
    return OffsetDateTime.parse(value.replace("Z", "+00:00"))
}

/** Creates a Jira issue asking for the given expiring token to be rotated. */
void createRotationIssue(Map token) {
    String summary = "Rotate expiring API token: ${token.label} (${token.accountName})"

    String description = """\
Service account API token is expiring soon and needs to be rotated.

Service Account : ${token.accountName} (${token.accountId})
Token Label     : ${token.label}
Token Status    : ${token.status}
Created         : ${token.createdAt}
Expires         : ${token.expiresAt} (${token.daysLeft} days left)
Last Active     : ${token.lastActive}
Renewal Link    : ${token.renewalLink}
"""

    def created = WorkItems.create(PROJECT_KEY, ISSUE_TYPE) {
        setSummary(summary)
        setDescription(description)
        setDueDate(token.dueDate as LocalDate)
    }

    logger.info("Created ${created.key} to rotate '${token.label}' (${token.daysLeft} days left)")
}

if (ORG_ID == "" || ADMIN_API_KEY == "") {
    throw new IllegalStateException("Please set ORG_ID and ADMIN_API_KEY in script variables.")
}

OffsetDateTime today = OffsetDateTime.now()
int candidates = 0

// GET /orgs/{orgId}/api-tokens returns every API token in the org (human users
// and service accounts alike); service account owners are identified by their
// @serviceaccount.atlassian.com email, since there's no dedicated accountType field.
String tokensUrl = "${BASE_URL}/admin/api-access/v1/orgs/${ORG_ID}/api-tokens"

for (Map token in paginatedGet(tokensUrl)) {
    Map user = token.user as Map
    String email = user?.email as String
    if (!email?.toLowerCase()?.endsWith("@serviceaccount.atlassian.com")) {
        continue
    }

    OffsetDateTime expiresAt = parseDate(token.expiresAt as String)
    if (!expiresAt) {
        continue
    }

    // Duration.toDays() truncates toward zero; only diverges from a floor-based
    // day count for already-expired (negative) tokens.
    long daysLeft = Duration.between(today, expiresAt).toDays()
    if (daysLeft < MIN_DAYS_AWAY || daysLeft > MAX_DAYS_AWAY) {
        continue
    }

    String accountId = user.id
    candidates++
    createRotationIssue([
        accountName: user.name ?: "N/A",
        accountId  : accountId,
        label      : token.label ?: "N/A",
        status     : token.status ?: "N/A",
        createdAt  : token.createdAt ?: "N/A",
        expiresAt  : token.expiresAt,
        dueDate    : expiresAt.toLocalDate(),
        daysLeft   : daysLeft,
        lastActive : token.lastActiveAt ?: "Never",
        renewalLink: "https://admin.atlassian.com/o/${ORG_ID}/service-accounts/${accountId}?tab=credentials".toString(),
    ])
}

logger.info("Done. ${candidates} token(s) expiring between ${MIN_DAYS_AWAY} and ${MAX_DAYS_AWAY} days processed.")

Note: I ran into 404s when testing the experimental Service Accounts endpoint. Since it's still marked experimental, it may not be fully rolled out or available to all orgs/auth types yet! But the general script structure can easily be adapted using AI once that endpoint is accessible.

Comment

Log in or Sign up to comment
TAGS
AUG Leaders

Atlassian Community Events