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.
⚠️
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 |