How to Migrate Off Bitbucket App Passwords Without Breaking Git (2026)
Key takeaways
- Pick the credential per consumer: an API token for a human, a repository access token for CI. A bot on someone's personal token dies when they leave.
- git credential fill prints the exact username and secret git would send. The username alone can't tell an API token from an app password, because both pair with your plain Bitbucket username. The secret's prefix can, and sending the pair settles it.
- Helpers run in configured order and the first answer wins, so a stale keychain entry beats the token you just stored. Verified on git 2.50.1.
- On macOS, git config --global --unset-all credential.helper often does nothing — the helper ships in Xcode's system gitconfig, which --global cannot reach.
- Prove the token against REST and git separately before cutting over. They use different usernames and fail independently.
- A token created without scopes will not work. The error you get back has several documented causes, so check scopes among them, not instead of them.
- Record the expiry the day you mint the token. You pick 1-365 days at creation and it cannot be edited afterwards, so rotate rather than extend.
Bitbucket Cloud removes app passwords on 28 July 2026. This walks through an actual migration rather than the announcement: how to find what is still affected when Bitbucket gives you no report, how to choose the right credential for each consumer, and how to prove the new one works before you break anything.
The centrepiece is a preflight script you run once per machine or CI job. It answers the question that causes most of the wasted time — which credential is actually going over the wire — instead of leaving you to infer it from a failure.
What you need. An Atlassian account on the workspace, git (I tested on 2.50.1), and Node 18+ for the preflight script. Everything here is read-only against Bitbucket except the token you create.
Step 0 — Choose the credential before you create anything
This is the decision that determines whether you do this migration once or twice. There are three replacements, they are not interchangeable, and the most common mistake is putting a person's token into a machine's config.
| Consumer | Use | Why |
|---|---|---|
| A developer's laptop | Atlassian API token with scopes | Acts as that human, with their access |
| One CI pipeline, one repo | Repository access token | Tied to the repo, not a person; survives them leaving |
| Automation across many repos | Project or workspace access token | Premium only; 25 tokens per workspace, and that cap cannot be raised |
| A third-party integration | Whatever it supports | Check its docs first — support is uneven |
If a build server currently runs on somebody's app password, do not replace it with that person's API token. You will reproduce the same failure the day they change role, and the token also carries their full access rather than the repository's. Use a repository access token.
And the fact that everything else in this tutorial depends on — the username changes with the credential, and with what you are doing:
| Credential | Username for git | Username for the REST API |
|---|---|---|
| Atlassian API token | your Bitbucket username, or the static x-bitbucket-api-token-auth or x-token-auth | your Atlassian account email with Basic auth, or none with Authorization: Bearer |
| Access token | x-token-auth | none — send Authorization: Bearer |
| App password (dead) | your Bitbucket username | your Bitbucket username |
Two notes on that first row, because both directions catch people. Atlassian documents your own Bitbucket username as the primary form for git, and it is case-sensitive — it has to match your Settings page exactly. The static x-bitbucket-api-token-auth is offered as an alternative and is the better choice for apps and CI, because it takes the per-person part out of the config. Either works. And an access token over REST does not use a username at all: it goes in an Authorization: Bearer <token> header. Since 18 August 2026 an API token can go in that header too. Then REST doesn't need your email at all. And x-token-auth isn't only an access token thing any more: Atlassian lists it next to x-bitbucket-api-token-auth as a static git username for API tokens.
Step 1 — Find what is still using an app password
There is no report. A workspace admin cannot list who is still authenticating with an app password, the REST API will not enumerate them, and the audit log records only App password added and App password removed with 30-day retention — credential lifecycle, never credential use. A token created in 2021 and used nightly ever since generates no audit events at all.
So the inventory is built by hand. Four places to look, in the order they usually hide:
# 1. What git has cached on this machine (see Step 3 for reading the answer)
printf "protocol=https\nhost=bitbucket.org\n\n" | git credential fill
# 2. Credentials baked directly into remote URLs, across every repo you have
find ~/code -name config -path "*/.git/*" -maxdepth 4 \
-exec grep -l "@bitbucket.org" {} \; 2>/dev/null
# 3. Which helpers are even in play, and which FILE configures each one
git config --show-origin --get-all credential.helper
# 4. CI: anything whose name suggests the old credential
grep -ril "app.password\|BITBUCKET_PASSWORD\|BB_APP_PASSWORD" \
.github/ .circleci/ bitbucket-pipelines.yml Jenkinsfile 2>/dev/null
Then the parts no script reaches: the secret stores for every CI system, the settings pane of every integration wired to Bitbucket, and any long-lived server with a git remote on it.
If you are reading this before the 28th, the brownout windows are a free discovery tool. Anything that fails inside a window and recovers after it is on an app password, and it names itself for you.
Step 2 — Create the token with the right scopes
Go to id.atlassian.com/manage-profile/security/api-tokens, then Create API token with scopes — not the plain "Create API token" button, which produces an unscoped token that cannot do git at all.
Name it after the consumer, not after yourself. jenkins-mobile-build tells the next person what breaks if they revoke it; token1 does not.
Set the expiry deliberately. You choose between 1 and 365 days at creation and the value cannot be edited afterwards, so plan to rotate rather than to extend. (An org admin on Atlassian Guard Standard can set an authentication policy that overrides token expiry, which is worth knowing before you build a rotation runbook around a date.)
Select Bitbucket as the app, then the scopes:
| Operation | Scope |
|---|---|
| Clone / fetch | read:repository:bitbucket |
| Push | write:repository:bitbucket |
| Read pull requests | read:pullrequest:bitbucket |
| Create / update pull requests | write:pullrequest:bitbucket |
| Webhooks (most CI integrations) | write:webhook:bitbucket |
A token created with no scopes will not work. Atlassian is explicit about it: "API tokens used to access Bitbucket APIs or perform Git commands must have scopes." What you get back is often "You may not have access to this repository or it no longer exists in this workspace", which reads like a permissions or a typo problem and sends people off checking the URL. Do not treat that string as proof of a scopeless token though — Atlassian documents several causes for it, so check the scopes among other things rather than instead of them.
For a repository access token instead, go to Repository settings → Access tokens → Create access token, which gives you a token scoped to that one repository and owned by it rather than by you.
Step 3 — Prove the token works before you cut over
This is the step people skip, and skipping it is why a migration turns into an outage. The REST API and git use different usernames for the same token, so they can and do fail independently. Test both, against the credential you are about to deploy, while the old one is still in place.
Save this as bb-preflight.mjs:
#!/usr/bin/env node
/**
* bb-preflight.mjs — prove a Bitbucket credential works BEFORE you cut over to it.
*
* Usage:
* BB_EMAIL=you@example.com BB_TOKEN=... \
* node bb-preflight.mjs --repo workspace/repository [--type api-token|access-token]
*
* Nothing here writes to your repo, your config, or your credential store.
*/
import { execFile, spawnSync } from "node:child_process";
import { promisify } from "node:util";
const exec = promisify(execFile);
const args = process.argv.slice(2);
const argOf = (name) => {
const i = args.indexOf(name);
return i === -1 ? undefined : args[i + 1];
};
const REPO = argOf("--repo");
const TYPE = argOf("--type") ?? "api-token";
const EMAIL = process.env.BB_EMAIL;
const TOKEN = process.env.BB_TOKEN;
if (!TOKEN || !REPO) {
console.error("usage: BB_EMAIL=you@example.com BB_TOKEN=... node bb-preflight.mjs --repo workspace/repo [--type api-token|access-token]");
process.exit(2);
}
if (TYPE === "api-token" && !EMAIL) {
console.error("This script sends an API token to REST as Basic auth, which needs BB_EMAIL (your Atlassian account email).");
process.exit(2);
}
// The username depends on the credential type AND on what you are doing with it.
const REST_USER = TYPE === "access-token" ? "(bearer, no username)" : EMAIL;
const GIT_USER = TYPE === "access-token" ? "x-token-auth" : "x-bitbucket-api-token-auth";
const pass = (m) => console.log(` PASS ${m}`);
const fail = (m) => console.log(` FAIL ${m}`);
const warn = (m) => console.log(` WARN ${m}`);
const info = (m) => console.log(` .... ${m}`);
let failures = 0;
let warnings = 0;
// git normally sends its first request WITHOUT a credential and only sends one after a 401.
// A public repo answers that first request with 200, so any secret at all would "work".
// http.proactiveAuth (git 2.46+) sends it up front. The secret goes in through the environment
// to a one-shot helper, never on the command line where other users could read it in ps, and
// credential.helper= first switches every real helper off, so git neither stores nor erases anything.
const GITV = (spawnSync("git", ["--version"], { encoding: "utf8" }).stdout ?? "").match(/(\d+)\.(\d+)/);
const PROACTIVE = Boolean(GITV) && (+GITV[1] > 2 || (+GITV[1] === 2 && +GITV[2] >= 46));
const ONE_SHOT = '!f() { test "$1" = get && printf "username=%s\\npassword=%s\\n" "$BBPF_USER" "$BBPF_SECRET"; }; f';
const lsRemote = (user, secret) =>
exec("git", ["-c", "credential.helper=", "-c", `credential.helper=${ONE_SHOT}`, "-c", "http.proactiveAuth=basic",
"ls-remote", "--heads", `https://bitbucket.org/${REPO}.git`], {
timeout: 30000,
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", BBPF_USER: user, BBPF_SECRET: secret },
});
const lastLines = (e, err) =>
err.split("\n").filter(Boolean).slice(-2).join(" | ") || (e.killed ? "git timed out after 30s" : "git failed with no output");
const PURGE = 'purge it: printf "protocol=https\\nhost=bitbucket.org\\n\\n" | git credential reject';
const basic = (u, p) => "Basic " + Buffer.from(`${u}:${p}`).toString("base64");
const redact = (s) => (s.length <= 8 ? "***" : `${s.slice(0, 4)}...${s.slice(-4)}`);
async function api(path, user, secret) {
// An access token goes in as a BEARER token with no username; this script sends an
// API token as Basic auth with your Atlassian account email (Bitbucket has also taken
// API tokens as Bearer since 18 Aug 2026). Sending an access token as Basic with a
// made-up username is the classic reason REST 401s while git works fine.
const auth = TYPE === "access-token" ? `Bearer ${secret}` : basic(user, secret);
const res = await fetch(`https://api.bitbucket.org/2.0${path}`, {
headers: { Authorization: auth, Accept: "application/json" },
});
let body = null;
try { body = await res.json(); } catch { /* 401s come back with an empty body */ }
return { status: res.status, body };
}
// ---------------------------------------------------------------- 1. cached
console.log("\n[1] What credential does git currently have cached for bitbucket.org?");
let cached = {};
{
// NB: git credential fill reads the query on STDIN, so this has to be spawnSync
// (execFile has no `input` option — it silently sends nothing and you get a false "nothing cached").
const r = spawnSync("git", ["credential", "fill"], {
input: "protocol=https\nhost=bitbucket.org\n\n",
encoding: "utf8",
timeout: 5000,
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
});
cached = Object.fromEntries(
(r.stdout ?? "").trim().split("\n").filter(Boolean).map((l) => {
const i = l.indexOf("=");
return [l.slice(0, i), l.slice(i + 1)];
}),
);
const u = cached.username ?? "";
const p = cached.password ?? "";
// The username alone cannot tell an API token from an app password: your plain
// Bitbucket username is a documented git username for an API token, and it is also
// what an app password used. The secret's prefix can. Atlassian staff confirmed these
// on the Community: API token ATAT, app password ATBB, access token ATCT.
const kind = p.startsWith("ATBB") ? "app password"
: p.startsWith("ATAT") ? "API token"
: p.startsWith("ATCT") ? "access token"
: null;
if (!u && !p) {
info("nothing cached — git would prompt");
} else if (kind === "app password") {
fail(`cached username is "${u}" and the secret starts with ATBB — an APP PASSWORD is still on the wire`);
info(PURGE);
failures++;
} else if (u.includes("@")) {
fail(`cached username is "${u}" — Atlassian says not to use your email as the git username`);
info("use your Bitbucket username or x-bitbucket-api-token-auth, then " + PURGE);
failures++;
} else if (p === TOKEN) {
pass(`cached username is "${u}" and the secret is the token you are testing`);
} else if (kind) {
pass(`cached username is "${u}" and the secret is an ${kind}, not an app password`);
} else {
warn(`cached username is "${u}", and neither it nor the secret's format says what this is`);
info("a plain Bitbucket username fits an API token and an app password alike — check [5] sends it and finds out");
warnings++;
}
}
// ---------------------------------------------------------------- 2. REST
console.log(`\n[2] REST auth as "${REST_USER}" (token ${redact(TOKEN)})`);
const me = await api("/user", REST_USER, TOKEN);
if (me.status === 200) {
pass(`authenticated as ${me.body?.display_name ?? "?"} (${me.body?.nickname ?? "?"})`);
} else if (me.status === 401) {
fail("401 Unauthorized — wrong username for this credential type, a bad/expired token, or an app password during a brownout");
info(`an API token needs your Atlassian ACCOUNT EMAIL here, not your Bitbucket username`);
failures++;
} else if (me.status === 403) {
fail("403 Forbidden — the credential authenticated but is missing read:account or read:user:bitbucket");
failures++;
} else {
fail(`unexpected HTTP ${me.status}`);
failures++;
}
// ---------------------------------------------------------------- 3. repo
console.log(`\n[3] Repository read: ${REPO}`);
const repo = await api(`/repositories/${REPO}`, REST_USER, TOKEN);
if (repo.status === 200) {
pass(`can read ${repo.body?.full_name} (${repo.body?.is_private ? "private" : "public"})`);
} else if (repo.status === 403 || repo.status === 404) {
fail(`HTTP ${repo.status} — "may not have access / no longer exists"; check the token SCOPES first, though this status has other causes too`);
info("clone needs read:repository:bitbucket; push also needs write:repository:bitbucket");
failures++;
} else if (repo.status === 401) {
fail("401 — the credential itself was rejected; fix check [2] first");
failures++;
} else {
fail(`unexpected HTTP ${repo.status}`);
failures++;
}
if (repo.status === 200 && repo.body?.is_private === false && !PROACTIVE) {
warn(`${REPO} is public and this git is older than 2.46, so checks [4] and [5] can pass without sending any credential — use a private repo`);
warnings++;
}
// ---------------------------------------------------------------- 4. git
console.log(`\n[4] Git over HTTPS as "${GIT_USER}"`);
try {
// Never let a real helper or a prompt answer for us — we are testing THIS credential.
const { stdout } = await lsRemote(GIT_USER, TOKEN);
const n = stdout.trim().split("\n").filter(Boolean).length;
pass(`clone/fetch works — ${n} branch${n === 1 ? "" : "es"} visible`);
} catch (e) {
const err = String(e.stderr ?? e.message);
if (err.includes("CHANGE-3222")) {
fail("CHANGE-3222 — an APP PASSWORD is being sent, not this token");
} else if (/error: 410\b/.test(err)) {
fail("HTTP 410 — app password rejected (brownout, or after 28 Jul 2026 permanently)");
} else if (/error: 403\b/.test(err)) {
fail("HTTP 403 — authenticated but missing read:repository:bitbucket");
} else if (/error: 401\b|Authentication failed/.test(err)) {
fail("401 — wrong git username for this credential type, or a bad token");
info(`API token -> x-bitbucket-api-token-auth | access token -> x-token-auth`);
} else {
fail(lastLines(e, err));
}
failures++;
}
// ---------------------------------------------------------------- 5. cached, on the wire
// Check [1] only reads what is cached. This sends it, the way git would, through lsRemote above.
console.log("\n[5] Does the credential git has cached actually work?");
if (!cached.username || !cached.password) {
info("nothing cached — skipped");
} else {
try {
await lsRemote(cached.username, cached.password);
pass(`git accepts the cached "${cached.username}" credential, so it is not an app password (those stopped working on 28 Jul 2026)`);
} catch (e) {
const err = String(e.stderr ?? e.message);
// Match git's own wording, not bare digits: a repo called app-410 must not read as a 410.
if (err.includes("CHANGE-3222") || /error: 410\b/.test(err)) {
fail("CHANGE-3222 / HTTP 410 — the cached credential is an APP PASSWORD");
info(PURGE);
} else if (/error: 401\b|Authentication failed/.test(err)) {
fail("401 — the cached credential was rejected: a bad or expired token, or the wrong username for it");
info(PURGE + " (then re-authenticate with the new token)");
} else if (/error: 40[34]\b|not found|may not have access/i.test(err)) {
fail(`the cached credential authenticated but cannot read ${REPO} — check its scopes and repository access`);
} else {
fail(lastLines(e, err));
}
failures++;
}
}
console.log(
failures > 0
? `\n${failures} check(s) failed — do NOT cut over yet.\n`
: warnings > 0
? `\nNo check failed, but ${warnings} warning(s) above — read them before you cut over.\n`
: "\nAll checks passed. Safe to cut over.\n",
);
process.exit(failures === 0 ? 0 : 1);
Run it:
BB_EMAIL="you@example.com" BB_TOKEN="ATATT..." \
node bb-preflight.mjs --repo myworkspace/myrepo
Point --repo at a private repository you can read. On a public one git doesn't send a credential unless it's asked for one, so the script sends it up front, and on a git older than 2.46 it warns you instead.
A machine that has not been migrated yet looks like this — and note that check [1] is telling you the migration is not done regardless of how good the new token is:
[1] What credential does git currently have cached for bitbucket.org?
FAIL cached username is "mihai_old_bb_user" and the secret starts with ATBB — an APP PASSWORD is still on the wire
.... purge it: printf "protocol=https\nhost=bitbucket.org\n\n" | git credential reject
[2] REST auth as "you@example.com" (token ATAT...0000)
PASS authenticated as Gabriela Perdum (gabriela)
...
[5] Does the credential git has cached actually work?
FAIL CHANGE-3222 / HTTP 410 — the cached credential is an APP PASSWORD
.... purge it: printf "protocol=https\nhost=bitbucket.org\n\n" | git credential reject
Check [1] is the one worth internalising.
git credential fillprints the exact username and password git would hand to Bitbucket. But the username alone can't tell you which credential it is. Your plain Bitbucket username is right for an API token, and it's also what an app password used. So check [1] reads the secret too. API tokens start with ATAT, app passwords with ATBB and access tokens with ATCT, as Atlassian staff confirmed on the Community. An email in the username slot is wrong whatever the secret is, because Atlassian says not to use your email as the git username. And when the format doesn't settle it, check [5] sends the cached pair with every helper switched off. A 410 with CHANGE-3222 back means it's an app password.It also prints a live secret to stdout. Do not run it while screen sharing, and do not run it in CI where output is captured.
Step 4 — Purge the cached credential
Now the part that makes people think their token is broken when it is fine. Replacing a credential does not remove the old one, and helpers answer in the order they are configured, first answer wins. A stale keychain entry beats the token you just stored.
Here is that happening, on my machine, while writing this. I stored a token in a file helper, and git kept returning the app password:
$ cat /tmp/bbtest/creds
https://x-bitbucket-api-token-auth:ATATT-good@bitbucket.org
$ printf "protocol=https\nhost=bitbucket.org\n\n" | git credential fill
protocol=https
host=bitbucket.org
username=mihai_old_bb_user # <- the OLD app password wins
password=ATBB-legacy-app-password
The reason is visible only with --show-origin:
$ git config --show-origin --get-all credential.helper
file:/Applications/Xcode.app/.../git-core/gitconfig osxkeychain
file:/tmp/bbtest/gc store --file=/tmp/bbtest/creds
file:.git/config osxkeychain
Three helpers, and osxkeychain is first. The new credential was never consulted.
The macOS trap. The usual advice is
git config --global --unset-all credential.helper. On a Mac with Xcode installed that does nothing and reports no error, because the helper is not in your global config — it ships in Xcode's system gitconfig, a scope--globalcannot reach. Verified on git 2.50.1:git config --global --get-all credential.helperreturns nothing whilegit config --get credential.helperreturnsosxkeychain. Always diagnose with--show-originso you know which file to edit.
Purge, then confirm it is gone:
# Portable — asks every configured helper to forget bitbucket.org
printf "protocol=https\nhost=bitbucket.org\n\n" | git credential reject
# macOS, if the keychain entry survives
printf "protocol=https\nhost=bitbucket.org\nusername=YOUR_OLD_USERNAME\n\n" \
| git credential-osxkeychain erase
# Windows
cmdkey /list | findstr bitbucket
cmdkey /delete:git:https://bitbucket.org
# Confirm — this should now print nothing, or prompt
printf "protocol=https\nhost=bitbucket.org\n\n" | git credential fill
On Windows, remember that git talks to Git Credential Manager through git's own config, independently of any GUI client. Clearing SourceTree's Accounts tab does not touch it, and a plain SourceTree reinstall usually does not either.
Step 5 — Cut over
- Strip credentials out of remote URLs a remote like https://olduser@bitbucket.org/ws/repo.git pins the username regardless of what your helper holds. Reset it with git remote set-url origin https://bitbucket.org/ws/repo.git and let the helper supply the credential.
- Re-authenticate once run any git fetch. When prompted, give your Bitbucket username (case-sensitive) or the static x-bitbucket-api-token-auth as the username and the API token as the password, and the helper stores the new pair.
- Update CI secrets replace the app password value. Whether the username has to change depends on what reads it: git takes your Bitbucket username with an API token, so that can stay, but anything calling the REST API with Basic auth needs your Atlassian account email, and a repository access token needs x-token-auth. This is the step most teams half-do: they swap the secret and leave a username that no longer fits, which fails as surely as leaving the old password.
- Update integrations expect the UI to lag the docs. SonarQube, for one, still instructs you verbatim to enter the API token into a field labelled App password. A field name is not evidence of what the field wants.
- Re-run the preflight on the migrated machine, and on a CI job. Checks [1] and [5] should pass now too, alongside [2]. On a CI runner with nothing cached they'll say so and skip, which is fine.
Step 6 — Verify, honestly
Getting one green push is not verification. Confirm all four:
git credential fill returns your new token, and preflight checks [1] and [5] both pass.
A git fetch and a git push both succeed — they need different scopes, and a read-only token passes the first and fails the second.
Whatever your integration does through the REST API works, tested through the integration itself rather than with curl.
Nothing on your Step 1 inventory is unaccounted for.
After 28 July, one diagnostic inverts. Until the removal, intermittent auth failure was the tell for a brownout. Afterwards an app password never works, so the failure is constant. A constant 410 with CHANGE-3222 means you are still sending an app password; a constant 401 means the token is being sent and something about it or its username is wrong. An intermittent one after that date is a different problem — a stale credential racing a good one across two helpers, a proxy, or scopes that cover some operations and not others — and treating it as the deprecation will send you to fix a credential that was already correct.
Step 7 — Write down the expiry
The migration is not finished when the token works.
API tokens expire, you pick between 1 and 365 days at creation, and the value cannot be edited afterwards — you rotate, you do not extend. So every token minted during this week's rush dies together in late July 2027, created by people under deadline pressure who will not remember doing it. (One exception worth knowing before you build a runbook around that date: an org admin on Atlassian Guard Standard can set an authentication policy that overrides token expiry.)
So finish the job properly: record the token name, its consumer, its owner and its expiry date somewhere a human will look, and set a reminder a few weeks ahead. Prefer repository access tokens for anything automated, because they at least outlive the person who created them.
Troubleshooting
| Symptom | Actual cause |
|---|---|
| CHANGE-3222 after switching to a token | The token is not on the wire — a cached credential is still sending the app password |
| "You may not have access to this repository" | Often a token created without scopes, but Atlassian documents other causes too — check scopes first, not only |
| 401 on REST, git works | You used your Bitbucket username instead of your account email with Basic auth (or skip the email and send the token as Bearer) |
| 401 on git, REST works | Wrong git username — your Bitbucket username, x-bitbucket-api-token-auth or x-token-auth for an API token, x-token-auth for an access token |
| Fetch works, push fails | Missing write:repository:bitbucket |
| Cleared the client, still fails | Git asks the credential helper, not your GUI client — see Step 4 |
| unset --global changed nothing | The helper is in Xcode's system gitconfig; check --show-origin |
| Intermittent failure before 28 Jul | A brownout window |
| Intermittent failure after 28 Jul | Not the deprecation — look for competing helpers or partial scopes |
What I verified, and what I did not
Being precise about this, because the script touches credentials.
Verified on git 2.50.1 against live Bitbucket: the 401 paths of checks [2], [3] and [4], the argument guards and exit codes, and the credential-helper ordering and Xcode system-gitconfig behaviour shown in Step 4, which I reproduced deliberately.
I got check [1] wrong in the first version, sorry. It failed a valid API token cached under a plain Bitbucket username, because it treated that username as proof of an app password. It isn't. I fixed it on 2 October 2026 and ran checks [1] and [5] on git 2.54.0, against a store helper in a throwaway HOME and a local stand-in for bitbucket.org that only replays what Atlassian documents. Fifteen cases went through: a plain username holding the token under test, another API token, a revoked one, an ATBB app password or an unprefixed secret; the static usernames with a token and with an ATBB secret; an email; an empty store; and five on awkward repos, one with no access, one called app-410, and three on a public repo (one with git pretending to be 2.45). Each one landed where it should. That run caught two more things. On success, the old check [4] quietly saved the token you were testing into your credential helper. And on a public repo git never sent the credential at all, so [4] and [5] passed anything. Both are fixed. The store comes out byte for byte the same, and the credential goes up front on git 2.46 or newer, while an older git gets a warning.
Not verified: the success paths. I wrote this without a Bitbucket workspace to point it at, so the 200-response branches, the 403-missing-scopes branches and the CHANGE-3222 branches, check [5]'s included, are built from the documented responses rather than observed. They are the simplest paths in the script, but run it against a private repository you can already reach before you trust its green output on one you cannot.
The script is read-only. It creates nothing, writes to no config, and stores no credential.
Sources. Using API tokens, Using access tokens, Access tokens and Manage API tokens; the brownout notice; Bitbucket Cloud audit log events; Renovate's Bitbucket scopes; SonarQube's Bitbucket Cloud integration docs.
Originally published on leanzero.net. More Atlassian, Forge and local-AI write-ups at leanzero.net/blog, and if you're planning a migration or a Forge app, that's what we do: leanzero.net/services.
Top comments (0)