OAuth 2.0 (3LO) authentication
jira.js supports the full Atlassian OAuth 2.0 (3LO) authorization-code flow: it ships stateless helpers to drive the browser consent flow, and an auto-refreshing client that keeps the access token fresh, transparently handles the rotating refresh token, and resolves the cloudId so requests go through the Atlassian API gateway.
This page is about Jira Cloud. A self-hosted instance is its own authorization server: there is no
cloudIdto resolve and nothing goes nearauth.atlassian.com. See Jira Data Center for that flow.
⚠️
clientSecretand refresh are server-side only. Never ship the client secret or refresh token to a browser. The simple{ accessToken }mode (no refresh) is the only part safe for a browser, and even then the token is a user credential.
🧪 Want to try it right now? The repo ships a runnable playground at
playground/oauth2/: fill inclientId/clientSecret, runnpm start, and it drives the whole browser flow and prints the Jira response for you.
When to use which auth method
| Method | Acts as | Where | Use when |
|---|---|---|---|
| Basic (email + API token) | a user | server | quick scripts, personal automation |
| OAuth 2.0 (3LO) | a user | server (refresh) / browser (static token) | acting on behalf of end users; multi-user apps |
| JWT (Connect) | the app | server only | existing Atlassian Connect app installations |
How it works
- You redirect the user to Atlassian's consent screen (
generateAuthorizationUrl). Includeoffline_accessin the scopes to receive a refresh token. - Atlassian redirects back to your callback with a one-time
code. You exchange it for tokens (exchangeAuthorizationCode) →accessToken,refreshToken,expiresIn. - You create a
jira.jsclient with those tokens plus your client id/secret. From then on the client:- auto-refreshes the access token shortly before it expires and again if a request returns
401; - rotates the refresh token — Atlassian returns a new refresh token on every refresh and invalidates the old one — and calls your
onTokenRefreshso you can persist it; - resolves the
cloudIdfromaccessible-resourcesand routes requests throughhttps://api.atlassian.com/ex/jira/{cloudId}(3LO tokens do not work againstyour-domain.atlassian.net).
- auto-refreshes the access token shortly before it expires and again if a request returns
Prerequisites
- An OAuth 2.0 (3LO) app in the developer console.
- Node.js ≥ 22 and
jira.js≥ 6.0.0. - A server endpoint to receive the OAuth callback (the redirect URL you register below).
Step 1 — Create the OAuth 2.0 (3LO) app
In the developer console:
- Create → OAuth 2.0 integration. Give it a name, pick an Access type (Resource-level restricts the token to the one site the user selects at consent; Account-level grants every site in the account), accept the developer terms, and click Create.
- Under Permissions, add the Jira API → Configure. On the Classic scopes tab click Edit Scopes and select the scopes you need (e.g.
read:jira-user,read:jira-work,write:jira-work), then save. To get a refresh token you must also requestoffline_access— that one is added via the authorization URL (see Step 2), not here. - Under Authorization, set the Callback URL to your server's redirect endpoint, e.g.
https://app.example.com/oauth/callback. - Under Settings, copy the Client ID and Secret.
With a Resource-level app the consent screen shows a Choose a site picker — the user must select a site before Accept is enabled. The granted token then reaches only that site (which is what
getAccessibleResources/ the auto-cloudIdresolution returns).
Step 2 — Redirect the user to consent
Generate the authorization URL and redirect the user. Always pass a random state and verify it on the callback (CSRF protection). Include offline_access to receive a refresh token.
import express from 'express';
import { generateAuthorizationUrl } from 'jira.js';
const app = express();
app.get('/oauth/login', (req, res) => {
const state = crypto.randomUUID();
// persist `state` against the user session to verify it on callback
const url = generateAuthorizationUrl({
clientId: process.env.OAUTH_CLIENT_ID!,
scopes: ['read:jira-work', 'write:jira-work', 'offline_access'],
redirectUri: 'https://app.example.com/oauth/callback',
state,
});
res.redirect(url);
});Step 3 — Handle the callback and store the tokens
import { exchangeAuthorizationCode } from 'jira.js';
app.get('/oauth/callback', async (req, res) => {
const { code, state } = req.query as { code: string; state: string };
// verify `state` matches the one you stored, then:
const tokens = await exchangeAuthorizationCode({
clientId: process.env.OAUTH_CLIENT_ID!,
clientSecret: process.env.OAUTH_CLIENT_SECRET!,
code,
redirectUri: 'https://app.example.com/oauth/callback',
});
await saveTokens(req.user.id, {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken, // present because we requested offline_access
expiresAt: Date.now() + tokens.expiresIn * 1000,
});
res.redirect('/');
});Step 4 — Create the client with auto-refresh + auto-cloudId
Pass the stored tokens plus your client id/secret. With no host, the client resolves the cloudId automatically; pass siteUrl or cloudId to pin a specific site (recommended for multi-site users). onTokenRefresh is called after every refresh — persist the rotated values.
import { createCloudClient } from 'jira.js';
const stored = await loadTokens(userId);
const jira = createCloudClient({
// no `host`: cloudId is resolved via accessible-resources; or set `siteUrl`/`cloudId` below
auth: {
type: 'oauth2',
accessToken: stored.accessToken,
refreshToken: stored.refreshToken,
clientId: process.env.OAUTH_CLIENT_ID!,
clientSecret: process.env.OAUTH_CLIENT_SECRET!,
expiresAt: stored.expiresAt, // epoch ms; lets the client refresh proactively
// siteUrl: 'https://your-domain.atlassian.net', // optional: disambiguate the site
// cloudId: 'xxxxxxxx-....', // optional: skip the accessible-resources lookup
onTokenRefresh: async ({ accessToken, refreshToken, expiresAt }) => {
// CRITICAL: persist the rotated tokens; the previous refresh token is now invalid
await saveTokens(userId, { accessToken, refreshToken, expiresAt });
},
},
});Need more than the platform surface? Build the client once with createClient from jira.js/core and pass it to each factory — one client means one token state, and since the refresh token rotates, two would invalidate each other.
Step 5 — Make requests
const me = await jira.myself.getCurrentUser();
console.log(me.displayName); // a 200 means OAuth 2.0 worksEach request gets a valid bearer token (refreshed if needed) and is routed to https://api.atlassian.com/ex/jira/{cloudId}.
Rotating refresh tokens (must read)
Atlassian uses rotating refresh tokens:
- Every refresh returns a new refresh token and invalidates the one you used.
jira.jssurfaces the new token viaonTokenRefresh— you must persist it, or the next process start will use a dead token and fail.- The refresh token has a 90-day inactivity expiry that resets on each use, and a 10-minute reuse leeway that absorbs transient concurrency.
jira.js collapses concurrent refreshes into a single network call (single-flight), so a burst of requests rotates the token exactly once.
Finding the cloudId yourself (optional)
If you prefer to resolve and store the cloudId out of band:
import { getAccessibleResources } from 'jira.js';
const resources = await getAccessibleResources(accessToken);
// resources[].id is the cloudId; match resources[].url to your siteRefreshing tokens manually (optional)
The client refreshes automatically, but the helper is exported if you need it (e.g. a background job). Remember to persist the rotated refreshToken.
import { refreshOAuth2Token } from 'jira.js';
const next = await refreshOAuth2Token({
clientId: process.env.OAUTH_CLIENT_ID!,
clientSecret: process.env.OAUTH_CLIENT_SECRET!,
refreshToken: stored.refreshToken,
});
await saveTokens(userId, {
accessToken: next.accessToken,
refreshToken: next.refreshToken,
expiresAt: Date.now() + next.expiresIn * 1000,
});Troubleshooting
- No
refreshTokenreturned — you didn't requestoffline_accessin the scopes. Add it and re-consent. - 401 after a refresh — the client secret is wrong, or the grant was revoked / the refresh token already rotated (lost a persisted update). Re-run the consent flow.
Multiple accessible Jira resources found— the token can reach more than one site. PasssiteUrlorcloudId.- Requests to
your-domain.atlassian.netfail with 401 — 3LO tokens only work through the gateway. Let the client route automatically (omithost) or setcloudId/siteUrl. invalid_granton refresh — the refresh token is expired (90-day inactivity) or was not the latest rotated value. Re-authorize.
Security checklist
- Never expose
clientSecretor refresh tokens to a browser or commit them to source control. - Persist the rotated tokens atomically inside
onTokenRefresh(last write wins). - Store tokens encrypted at rest, keyed per user.
- Always validate the
stateparameter on the callback.
Reference links
| Topic | Link |
|---|---|
| OAuth 2.0 (3LO) apps | OAuth 2.0 (3LO) apps |
| Developer console (create app) | developer.atlassian.com/console/myapps |
| Scopes | OAuth 2.0 scopes |
| Accessible resources / cloudId | Get accessible resources |