The REST API uses OAuth 2.0 (3LO) — every request must include a valid Bearer token.
Authorization: Bearer YOUR_ACCESS_TOKEN
Access tokens expire after 1 hour. For automated pipelines, use refresh token rotation to obtain new tokens without user interaction.
Both scopes must be present in your OAuth integration before authorizing:
| Scope | Purpose |
|---|---|
export:markdown:custom |
Grants access to the export endpoints |
read:forge-app:confluence |
Required for Forge API route access |
Include offline_access in the authorization URL to receive a refresh token.
curl --request POST \
--url 'https://auth.atlassian.com/oauth/token' \
--header 'Content-Type: application/json' \
--data '{
"grant_type": "refresh_token",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"refresh_token": "YOUR_STORED_REFRESH_TOKEN"
}'
The response includes a new access_token and a new refresh_token.
Important — rotating refresh tokens: Atlassian issues a new refresh token on every refresh call. Always save the latest refresh token returned. Reusing an old refresh token invalidates the entire chain and requires re-authorization from scratch.
Refresh tokens expire after 90 days of inactivity. Any active pipeline that refreshes at least once every 90 days keeps the token alive indefinitely.
import fs from 'fs';
const TOKEN_FILE = './tokens.json';
const CLIENT_ID = process.env.ATLASSIAN_CLIENT_ID;
const CLIENT_SECRET = process.env.ATLASSIAN_CLIENT_SECRET;
function loadTokens() {
return JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8'));
}
function saveTokens(tokens) {
fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2));
}
async function getValidToken() {
const tokens = loadTokens();
const payload = JSON.parse(Buffer.from(tokens.access_token.split('.')[1], 'base64').toString());
const expiresAt = payload.exp * 1000;
if (Date.now() < expiresAt - 5 * 60 * 1000) {
return tokens.access_token;
}
const res = await fetch('https://auth.atlassian.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
refresh_token: tokens.refresh_token,
}),
});
const newTokens = await res.json();
if (!newTokens.access_token) throw new Error(`Token refresh failed: ${JSON.stringify(newTokens)}`);
saveTokens({ access_token: newTokens.access_token, refresh_token: newTokens.refresh_token });
return newTokens.access_token;
}
import json, time, base64, os, requests
from pathlib import Path
TOKEN_FILE = Path('tokens.json')
CLIENT_ID = os.environ['ATLASSIAN_CLIENT_ID']
CLIENT_SECRET = os.environ['ATLASSIAN_CLIENT_SECRET']
def load_tokens():
return json.loads(TOKEN_FILE.read_text())
def save_tokens(tokens):
TOKEN_FILE.write_text(json.dumps(tokens, indent=2))
def get_valid_token():
tokens = load_tokens()
payload_b64 = tokens['access_token'].split('.')[1]
payload = json.loads(base64.b64decode(payload_b64 + '=='))
if time.time() < payload['exp'] - 300:
return tokens['access_token']
r = requests.post('https://auth.atlassian.com/oauth/token', json={
'grant_type': 'refresh_token',
'client_id': CLIENT_ID,
'client_secret': CLIENT_SECRET,
'refresh_token': tokens['refresh_token'],
})
r.raise_for_status()
new_tokens = r.json()
save_tokens({'access_token': new_tokens['access_token'], 'refresh_token': new_tokens['refresh_token']})
return new_tokens['access_token']
tokens.json or any file containing tokens to version control