Firefly Services Authentication
Obtains and refreshes IMS access tokens for Firefly Services using OAuth Server-to-Server credentials. Covers the token round-trip, the refresh-before-expiry pattern, scope selection per API surface, and the migration path off deprecated JWT credentials.
If credentials do not exist yet, run firefly-services-bootstrap first. If a 401/403 is being thrown despite a valid-looking token, use firefly-services-troubleshoot.
When to Use This Skill
Use this skill when:
- A Firefly Services API call needs an
Authorization: Bearer <token>header - A long-running service needs to refresh tokens before they expire (~24h validity)
- The user is migrating from deprecated JWT credentials to OAuth Server-to-Server
- A new endpoint needs different scopes added to the token request
- Auth is being wired into a backend service (Node, Python, Go) calling Firefly APIs
Do NOT use this skill when:
- A valid
FIREFLY_SERVICES_ACCESS_TOKENis already in scope and not near expiry — reuse it - The user is doing end-user delegated auth (the documented Firefly Services auth model is server-to-server; as of this writing there is no user-delegated path)
- Credentials do not yet exist — run
firefly-services-bootstrapfirst
Critical: JWT Credentials Have Reached End-of-Life
Service Account (JWT) credentials reached end-of-life on June 30, 2025. They continued to function only until their issuing certificate expired, and the final certificate expiry was March 1, 2026. That date has passed — every JWT-based integration is now non-functional, and there is no path to renew a JWT certificate.
If you see code like this anywhere, it must be migrated:
// Legacy JWT path — no longer functional (do not use)
const jwt = require('jsonwebtoken');
const token = jwt.sign({...}, privateKey, {algorithm: 'RS256'});
The migration is a one-time rebuild — you cannot incrementally upgrade JWT to OAuth. See JWT → OAuth Migration below.
Prerequisites
| Requirement | How to verify |
|---|---|
| OAuth S2S credentials provisioned | $FIREFLY_SERVICES_CLIENT_ID and $FIREFLY_SERVICES_CLIENT_SECRET exist as env vars |
| Workspace subscribed to the right API | Token must include the relevant Firefly scope (see scope table below) |
| Network access to IMS | curl -I https://ims-na1.adobelogin.com/ims/token/v3 returns 405 (POST-only) |
Scope Selection — Pick Only What You Need
The scope string in the token request controls what the token can access. Over-scoping is a security smell — request only the scopes the calling service actually needs.
| Scope | Required for |
|---|---|
openid |
Recommended baseline (Adobe getting-started default) |
AdobeID |
Recommended baseline (Adobe getting-started default) |
session |
Recommended baseline (Adobe getting-started default) |
additional_info |
Recommended baseline (Adobe getting-started default) |
read_organizations |
Recommended baseline for server-to-server; SDK-documented requirement for Photoshop/Lightroom |
firefly_api |
Firefly v1 / legacy endpoints |
ff_apis |
Firefly v2 / v3 endpoints (most current workloads) |
firefly_enterprise |
Custom Models API |
creative_sdk |
Legacy Photoshop/Lightroom scope — the SDK documents openid, AdobeID, read_organizations as the current requirement; including creative_sdk is harmless |
AdobeID,additional_info.projectedProductContext |
Some product-profile-gated services |
The SDK-documented per-API minimums are narrower than the baseline: Firefly is firefly_api, ff_apis; Photoshop and Lightroom are openid, AdobeID, read_organizations. The baseline rows are Adobe's getting-started default and are safe to include.
Always request both firefly_api and ff_apis when uncertain — the two scopes cover different endpoint generations, and the cost of including both is zero. Include firefly_enterprise only for projects using Custom Models. For Photoshop/Lightroom workloads, openid, AdobeID, read_organizations covers the SDK-documented requirement; creative_sdk is a legacy scope that remains harmless to include.
The canonical scope string for an FDE engagement that uses Firefly + Photoshop + custom models:
openid,AdobeID,session,additional_info,read_organizations,firefly_api,ff_apis,firefly_enterprise,creative_sdk
Step 1 — Retrieve an Access Token (Bash)
The IMS token endpoint accepts application/x-www-form-urlencoded. Tokens are valid for ~24 hours (expires_in: 86399 seconds).
curl --silent --location 'https://ims-na1.adobelogin.com/ims/token/v3' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "client_id=$FIREFLY_SERVICES_CLIENT_ID" \
--data-urlencode "client_secret=$FIREFLY_SERVICES_CLIENT_SECRET" \
--data-urlencode 'scope=openid,AdobeID,session,additional_info,read_organizations,firefly_api,ff_apis'
Successful response:
{"access_token": "eyJhbGc...", "token_type": "bearer", "expires_in": 86399}
Capture the token and expires_in together — the refresh-before-expiry pattern depends on knowing absolute expiry time.
Step 2 — Retrieve an Access Token (Node.js with SDK)
import { ServerToServerTokenProvider } from '@adobe/firefly-services-common-apis';
const tokenProvider = new ServerToServerTokenProvider(
{
clientId: process.env.FIREFLY_SERVICES_CLIENT_ID,
clientSecret: process.env.FIREFLY_SERVICES_CLIENT_SECRET,
// scopes is a single comma-separated string, not an array
scopes: 'openid,AdobeID,session,additional_info,read_organizations,firefly_api,ff_apis',
},
// autoRefresh defaults to false, and with it off getToken() throws until
// you call authenticate() yourself — pass true so getToken() fetches the
// initial token for you.
{ autoRefresh: true },
);
const accessToken = await tokenProvider.getToken();
ServerToServerTokenProvider caches the token in memory. With autoRefresh: true, getToken() fetches the initial token automatically. Note the shipped behavior: once a token is cached, getToken() returns the cached value without re-fetching — a token held past its ~24h lifetime is returned as-is. This makes the SDK path a good fit for scripts and short-lived jobs. For a long-running service, use the refresh-before-expiry cache in Step 3, which owns the token lifecycle explicitly.
Step 3 — Refresh Before Expiry (Production Pattern)
Tokens are valid for ~24 hours. A production service must refresh proactively, not reactively. Reactive refresh (on 401) means in-flight requests fail at the boundary.
Pattern:
class FireflyTokenCache {
constructor({ clientId, clientSecret, scopes, safetyBufferSec = 300 }) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.scopes = scopes.join(',');
this.safetyBuffer = safetyBufferSec * 1000;
this.token = null;
this.expiresAt = 0;
}
async getToken() {
if (this.token && Date.now() < this.expiresAt - this.safetyBuffer) {
return this.token;
}
await this.refresh();
return this.token;
}
async refresh() {
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
scope: this.scopes,
});
const res = await fetch('https://ims-na1.adobelogin.com/ims/token/v3', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!res.ok) {
throw new Error(`IMS token refresh failed: ${res.status} ${await res.text()}`);
}
const json = await res.json();
this.token = json.access_token;
this.expiresAt = Date.now() + json.expires_in * 1000;
}
}
Set safetyBufferSec to 5 minutes (300s) at minimum. For high-throughput services, use 15 minutes — the latency of a refresh under load is non-trivial and you do not want it on the request path.
Step 4 — Multi-Tenant Credential Isolation
When a single service calls Firefly Services on behalf of multiple customers, do not share a single set of credentials. Each customer engagement should have its own OAuth S2S credential pair, scoped to that customer's IMS org.
Why: a leaked or rotated credential should not affect other customers. Logs, rate limits, and audit trails are also per-credential — sharing credentials destroys attribution.
Pattern:
const tokenCaches = new Map(); // customerId → FireflyTokenCache
function getTokenCache(customerId) {
if (!tokenCaches.has(customerId)) {
const creds = loadCredsForCustomer(customerId); // from your secrets manager
tokenCaches.set(customerId, new FireflyTokenCache(creds));
}
return tokenCaches.get(customerId);
}
In multi-tenant FDE deployments (multiple enterprise customers all served by the same service), this pattern is mandatory.
JWT → OAuth Migration
If a project still carries JWT credentials, it is already broken — the final certificate expiry was March 1, 2026. The migration below is a rebuild of a dead integration, not a live cutover: there is no working JWT path to run in parallel.
Step A — Provision OAuth S2S credentials in the same workspace
Credential creation is a Developer Console UI step — the aio console plugin manages projects, workspaces, and API subscriptions, but has no credential-creation command. Deep-link straight to the existing project/workspace:
aio console open
In the Console UI, open the workspace that holds the JWT credential and add a new credential: Credentials → Add Credential (or Add API if prompted for a credential type), then choose OAuth Server-to-Server and name it <name>-oauth. The credential screen presents the client_id and client_secret — capture both immediately (the secret is not retrievable later, only rotatable) and store them in the secrets manager.
The expired JWT credential can coexist in the same workspace while you stand up OAuth — it no longer functions, so there is no traffic to cut over. Revoke it once the OAuth path is verified (Step C).
Step B — Replace JWT-signing code with OAuth client-credentials
Every place that signs a JWT and exchanges it at IMS is replaced with a single client-credentials request as shown in Step 1. The Node SDK does this for you (ServerToServerTokenProvider).
Step C — Cut traffic over and decommission JWT
After the OAuth path is verified in production, revoke the JWT credentials in Developer Console. Do not leave them active — abandoned credentials are an audit liability.
Troubleshooting & Edge Cases
invalid_client: Wrongclient_secret. Re-export$FIREFLY_SERVICES_CLIENT_SECRETfrom the secrets manager.unauthorized_client: The workspace owning these credentials is not subscribed to the API surface the scope is requesting. Re-runaio console workspace api addfor the missing service.- 200 OK but empty
access_token: Scope string is malformed or includes a scope the credential is not entitled to. Strip the scope list down to the required core set (openid,AdobeID,session,additional_info,read_organizations,firefly_api,ff_apis) and reintroduce one scope at a time. - Token valid but Firefly API returns 401: The token is real but does not include
firefly_apiorff_apisscope. Re-issue with the correct scope set. - Token valid but Firefly API returns 403: The token has the scope but the IMS org does not have the entitlement. Customer needs Adobe to provision Firefly Services for that org.
- Token works locally but fails in production: Different
client_id/client_secret. Multi-environment deployments need per-environment credentials — never share a credential between Stage and Prod. - Token works but in-flight requests sporadically 401: Refresh-on-expiry race. Increase the safety buffer (5 → 15 minutes) and make the refresh function idempotent (only one refresh in-flight per cache instance at a time).
- JWT certificate expired: The integration is non-functional until rebuilt on OAuth. There is no path to renew the JWT certificate.
Validate
Auth is correctly configured when:
curlagainst/ims/token/v3returns a non-emptyaccess_tokenandexpires_in: 86399- The token can be used to call at least one Firefly endpoint without 401
- The refresh-before-expiry pattern is in place — no service depends on reactive 401-and-retry
- Credentials are loaded from a secrets manager, not from
.envfiles in source control - Multi-tenant services have per-customer credentials
Chaining with Other Skills
firefly-services-troubleshoot— Decode auth errors deeper than this skill coversfirefly-generate-image-v3-async— First workload after auth is wiredfirefly-services-rate-limits— Token-aware retry and backoff