How to Migrate Off Bitbucket App Passwords Without Breaking Git (2026)
Gabriela Perdum
Author
14 min readJuly 22, 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.
Note
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
5 rows × 3 columnsHeader row enabled
Warning
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
4 rows × 3 columnsHeader row enabled
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:
bash
1# 1. What git has cached on this machine (see Step 3 for reading the answer)2printf"protocol=https\nhost=bitbucket.org\n\n"|git credential fill
34# 2. Credentials baked directly into remote URLs, across every repo you have5find ~/code -name config -path"*/.git/*"-maxdepth4\6-execgrep-l"@bitbucket.org"{}\;2>/dev/null
78# 3. Which helpers are even in play, and which FILE configures each one9git config --show-origin --get-all credential.helper
1011# 4. CI: anything whose name suggests the old credential12grep-ril"app.password\|BITBUCKET_PASSWORD\|BB_APP_PASSWORD"\13 .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.
Tip
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
6 rows × 2 columnsHeader row enabled
Caution
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:
js
1#!/usr/bin/env node2/**
3 * bb-preflight.mjs — prove a Bitbucket credential works BEFORE you cut over to it.
4 *
5 * Usage:
6 * BB_EMAIL=you@example.com BB_TOKEN=... \
7 * node bb-preflight.mjs --repo workspace/repository [--type api-token|access-token]
8 *
9 * Nothing here writes to your repo, your config, or your credential store.
10 */1112import{ execFile, spawnSync }from"node:child_process";13import{ promisify }from"node:util";1415const exec =promisify(execFile);1617const args = process.argv.slice(2);18constargOf=(name)=>{19const i = args.indexOf(name);20return i ===-1?undefined: args[i +1];21};2223constREPO=argOf("--repo");24constTYPE=argOf("--type")??"api-token";25constEMAIL= process.env.BB_EMAIL;26constTOKEN= process.env.BB_TOKEN;2728if(!TOKEN||!REPO){29console.error("usage: BB_EMAIL=you@example.com BB_TOKEN=... node bb-preflight.mjs --repo workspace/repo [--type api-token|access-token]");30 process.exit(2);31}32if(TYPE==="api-token"&&!EMAIL){33console.error("This script sends an API token to REST as Basic auth, which needs BB_EMAIL (your Atlassian account email).");34 process.exit(2);35}3637// The username depends on the credential type AND on what you are doing with it.38constREST_USER=TYPE==="access-token"?"(bearer, no username)":EMAIL;39constGIT_USER=TYPE==="access-token"?"x-token-auth":"x-bitbucket-api-token-auth";4041constpass=(m)=>console.log(` PASS ${m}`);42constfail=(m)=>console.log(` FAIL ${m}`);43constwarn=(m)=>console.log(` WARN ${m}`);44constinfo=(m)=>console.log(` .... ${m}`);45let failures =0;46let warnings =0;47// git normally sends its first request WITHOUT a credential and only sends one after a 401.48// A public repo answers that first request with 200, so any secret at all would "work".49// http.proactiveAuth (git 2.46+) sends it up front. The secret goes in through the environment50// to a one-shot helper, never on the command line where other users could read it in ps, and51// credential.helper= first switches every real helper off, so git neither stores nor erases anything.52constGITV=(spawnSync("git",["--version"],{encoding:"utf8"}).stdout??"").match(/(\d+)\.(\d+)/);53constPROACTIVE=Boolean(GITV)&&(+GITV[1]>2||(+GITV[1]===2&&+GITV[2]>=46));54constONE_SHOT='!f() { test "$1" = get && printf "username=%s\\npassword=%s\\n" "$BBPF_USER" "$BBPF_SECRET"; }; f';55constlsRemote=(user, secret)=>56exec("git",["-c","credential.helper=","-c",`credential.helper=${ONE_SHOT}`,"-c","http.proactiveAuth=basic",57"ls-remote","--heads",`https://bitbucket.org/${REPO}.git`],{58timeout:30000,59env:{...process.env,GIT_TERMINAL_PROMPT:"0",GIT_CONFIG_NOSYSTEM:"1",BBPF_USER: user,BBPF_SECRET: secret },60});61constlastLines=(e, err)=>62 err.split("\n").filter(Boolean).slice(-2).join(" | ")||(e.killed?"git timed out after 30s":"git failed with no output");63constPURGE='purge it: printf "protocol=https\\nhost=bitbucket.org\\n\\n" | git credential reject';6465constbasic=(u, p)=>"Basic "+Buffer.from(`${u}:${p}`).toString("base64");66constredact=(s)=>(s.length<=8?"***":`${s.slice(0,4)}...${s.slice(-4)}`);6768asyncfunctionapi(path, user, secret){69// An access token goes in as a BEARER token with no username; this script sends an70// API token as Basic auth with your Atlassian account email (Bitbucket has also taken71// API tokens as Bearer since 18 Aug 2026). Sending an access token as Basic with a72// made-up username is the classic reason REST 401s while git works fine.73const auth =TYPE==="access-token"?`Bearer ${secret}`:basic(user, secret);74const res =awaitfetch(`https://api.bitbucket.org/2.0${path}`,{75headers:{Authorization: auth,Accept:"application/json"},76});77let body =null;78try{ body =await res.json();}catch{/* 401s come back with an empty body */}79return{status: res.status, body };80}8182// ---------------------------------------------------------------- 1. cached83console.log("\n[1] What credential does git currently have cached for bitbucket.org?");84let cached ={};85{86// NB: git credential fill reads the query on STDIN, so this has to be spawnSync87// (execFile has no `input` option — it silently sends nothing and you get a false "nothing cached").88const r =spawnSync("git",["credential","fill"],{89input:"protocol=https\nhost=bitbucket.org\n\n",90encoding:"utf8",91timeout:5000,92env:{...process.env,GIT_TERMINAL_PROMPT:"0"},93});94 cached =Object.fromEntries(95(r.stdout??"").trim().split("\n").filter(Boolean).map((l)=>{96const i = l.indexOf("=");97return[l.slice(0, i), l.slice(i +1)];98}),99);100const u = cached.username??"";101const p = cached.password??"";102// The username alone cannot tell an API token from an app password: your plain103// Bitbucket username is a documented git username for an API token, and it is also104// what an app password used. The secret's prefix can. Atlassian staff confirmed these105// on the Community: API token ATAT, app password ATBB, access token ATCT.106const kind = p.startsWith("ATBB")?"app password"107: p.startsWith("ATAT")?"API token"108: p.startsWith("ATCT")?"access token"109:null;110if(!u &&!p){111info("nothing cached — git would prompt");112}elseif(kind ==="app password"){113fail(`cached username is "${u}" and the secret starts with ATBB — an APP PASSWORD is still on the wire`);114info(PURGE);115 failures++;116}elseif(u.includes("@")){117fail(`cached username is "${u}" — Atlassian says not to use your email as the git username`);118info("use your Bitbucket username or x-bitbucket-api-token-auth, then "+PURGE);119 failures++;120}elseif(p ===TOKEN){121pass(`cached username is "${u}" and the secret is the token you are testing`);122}elseif(kind){123pass(`cached username is "${u}" and the secret is an ${kind}, not an app password`);124}else{125warn(`cached username is "${u}", and neither it nor the secret's format says what this is`);126info("a plain Bitbucket username fits an API token and an app password alike — check [5] sends it and finds out");127 warnings++;128}129}130131// ---------------------------------------------------------------- 2. REST132console.log(`\n[2] REST auth as "${REST_USER}" (token ${redact(TOKEN)})`);133const me =awaitapi("/user",REST_USER,TOKEN);134if(me.status===200){135pass(`authenticated as ${me.body?.display_name ??"?"} (${me.body?.nickname ??"?"})`);136}elseif(me.status===401){137fail("401 Unauthorized — wrong username for this credential type, a bad/expired token, or an app password during a brownout");138info(`an API token needs your Atlassian ACCOUNT EMAIL here, not your Bitbucket username`);139 failures++;140}elseif(me.status===403){141fail("403 Forbidden — the credential authenticated but is missing read:account or read:user:bitbucket");142 failures++;143}else{144fail(`unexpected HTTP ${me.status}`);145 failures++;146}147148// ---------------------------------------------------------------- 3. repo149console.log(`\n[3] Repository read: ${REPO}`);150const repo =awaitapi(`/repositories/${REPO}`,REST_USER,TOKEN);151if(repo.status===200){152pass(`can read ${repo.body?.full_name} (${repo.body?.is_private ?"private":"public"})`);153}elseif(repo.status===403|| repo.status===404){154fail(`HTTP ${repo.status} — "may not have access / no longer exists"; check the token SCOPES first, though this status has other causes too`);155info("clone needs read:repository:bitbucket; push also needs write:repository:bitbucket");156 failures++;157}elseif(repo.status===401){158fail("401 — the credential itself was rejected; fix check [2] first");159 failures++;160}else{161fail(`unexpected HTTP ${repo.status}`);162 failures++;163}164165if(repo.status===200&& repo.body?.is_private ===false&&!PROACTIVE){166warn(`${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`);167 warnings++;168}169170// ---------------------------------------------------------------- 4. git171console.log(`\n[4] Git over HTTPS as "${GIT_USER}"`);172try{173// Never let a real helper or a prompt answer for us — we are testing THIS credential.174const{ stdout }=awaitlsRemote(GIT_USER,TOKEN);175const n = stdout.trim().split("\n").filter(Boolean).length;176pass(`clone/fetch works — ${n} branch${n ===1?"":"es"} visible`);177}catch(e){178const err =String(e.stderr?? e.message);179if(err.includes("CHANGE-3222")){180fail("CHANGE-3222 — an APP PASSWORD is being sent, not this token");181}elseif(/error: 410\b/.test(err)){182fail("HTTP 410 — app password rejected (brownout, or after 28 Jul 2026 permanently)");183}elseif(/error: 403\b/.test(err)){184fail("HTTP 403 — authenticated but missing read:repository:bitbucket");185}elseif(/error: 401\b|Authentication failed/.test(err)){186fail("401 — wrong git username for this credential type, or a bad token");187info(`API token -> x-bitbucket-api-token-auth | access token -> x-token-auth`);188}else{189fail(lastLines(e, err));190}191 failures++;192}193194// ---------------------------------------------------------------- 5. cached, on the wire195// Check [1] only reads what is cached. This sends it, the way git would, through lsRemote above.196console.log("\n[5] Does the credential git has cached actually work?");197if(!cached.username||!cached.password){198info("nothing cached — skipped");199}else{200try{201awaitlsRemote(cached.username, cached.password);202pass(`git accepts the cached "${cached.username}" credential, so it is not an app password (those stopped working on 28 Jul 2026)`);203}catch(e){204const err =String(e.stderr?? e.message);205// Match git's own wording, not bare digits: a repo called app-410 must not read as a 410.206if(err.includes("CHANGE-3222")||/error: 410\b/.test(err)){207fail("CHANGE-3222 / HTTP 410 — the cached credential is an APP PASSWORD");208info(PURGE);209}elseif(/error: 401\b|Authentication failed/.test(err)){210fail("401 — the cached credential was rejected: a bad or expired token, or the wrong username for it");211info(PURGE+" (then re-authenticate with the new token)");212}elseif(/error: 40[34]\b|not found|may not have access/i.test(err)){213fail(`the cached credential authenticated but cannot read ${REPO} — check its scopes and repository access`);214}else{215fail(lastLines(e, err));216}217 failures++;218}219}220221console.log(222 failures >0223?`\n${failures} check(s) failed — do NOT cut over yet.\n`224: warnings >0225?`\nNo check failed, but ${warnings} warning(s) above — read them before you cut over.\n`226:"\nAll checks passed. Safe to cut over.\n",227);228process.exit(failures ===0?0:1);
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:
text
1[1] What credential does git currently have cached for bitbucket.org?
2 FAIL cached username is "mihai_old_bb_user" and the secret starts with ATBB — an APP PASSWORD is still on the wire
3 .... purge it: printf "protocol=https\nhost=bitbucket.org\n\n" | git credential reject
45[2] REST auth as "you@example.com" (token ATAT...0000)
6 PASS authenticated as Gabriela Perdum (gabriela)
78...
910[5] Does the credential git has cached actually work?
11 FAIL CHANGE-3222 / HTTP 410 — the cached credential is an APP PASSWORD
12 .... purge it: printf "protocol=https\nhost=bitbucket.org\n\n" | git credential reject
Success
Check [1] is the one worth internalising. git credential fill prints 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:
bash
1$ cat /tmp/bbtest/creds
2https://x-bitbucket-api-token-auth:ATATT-good@bitbucket.org
34$ printf"protocol=https\nhost=bitbucket.org\n\n"|git credential fill
5protocol=https
6host=bitbucket.org
7username=mihai_old_bb_user # <- the OLD app password wins8password=ATBB-legacy-app-password
Three helpers, and osxkeychain is first. The new credential was never consulted.
Warning
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 --global cannot reach. Verified on git 2.50.1: git config --global --get-all credential.helper returns nothing while git config --get credential.helper returns osxkeychain. Always diagnose with --show-origin so you know which file to edit.
Purge, then confirm it is gone:
bash
1# Portable — asks every configured helper to forget bitbucket.org2printf"protocol=https\nhost=bitbucket.org\n\n"|git credential reject
34# macOS, if the keychain entry survives5printf"protocol=https\nhost=bitbucket.org\nusername=YOUR_OLD_USERNAME\n\n"\6|git credential-osxkeychain erase
78# Windows9cmdkey /list | findstr bitbucket
10cmdkey /delete:git:https://bitbucket.org
1112# Confirm — this should now print nothing, or prompt13printf"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
1
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.
2
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.
3
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.
4
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.
5
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 fetchand 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.
Tip
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
10 rows × 2 columnsHeader row enabled
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.