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 git would send. That username identifies the credential type — it is the only test that does not involve guessing.
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
your Atlassian account email
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.
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.
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("An Atlassian API token needs BB_EMAIL (your Atlassian account email) for REST calls.");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}`);43constinfo=(m)=>console.log(` .... ${m}`);44let failures =0;4546constbasic=(u, p)=>"Basic "+Buffer.from(`${u}:${p}`).toString("base64");47constredact=(s)=>(s.length<=8?"***":`${s.slice(0,4)}...${s.slice(-4)}`);4849asyncfunctionapi(path, user, secret){50// An access token goes in as a BEARER token with no username; an API token is Basic51// auth with your Atlassian account email. Sending an access token as Basic with a52// made-up username is the classic reason REST 401s while git works fine.53const auth =TYPE==="access-token"?`Bearer ${secret}`:basic(user, secret);54const res =awaitfetch(`https://api.bitbucket.org/2.0${path}`,{55headers:{Authorization: auth,Accept:"application/json"},56});57let body =null;58try{ body =await res.json();}catch{/* 401s come back with an empty body */}59return{status: res.status, body };60}6162// ---------------------------------------------------------------- 1. cached63console.log("\n[1] What credential does git currently have cached for bitbucket.org?");64{65// NB: git credential fill reads the query on STDIN, so this has to be spawnSync66// (execFile has no `input` option — it silently sends nothing and you get a false "nothing cached").67const r =spawnSync("git",["credential","fill"],{68input:"protocol=https\nhost=bitbucket.org\n\n",69encoding:"utf8",70timeout:5000,71env:{...process.env,GIT_TERMINAL_PROMPT:"0"},72});73const cached =Object.fromEntries(74(r.stdout??"").trim().split("\n").filter(Boolean).map((l)=>{75const i = l.indexOf("=");76return[l.slice(0, i), l.slice(i +1)];77}),78);79const u = cached.username??"";80if(!u){81info("nothing cached — git would prompt");82}elseif(u ==="x-bitbucket-api-token-auth"|| u ==="x-token-auth"|| u.includes("@")){83pass(`cached username is "${u}" — that is a token, not an app password`);84}else{85fail(`cached username is "${u}" — a bare username means an APP PASSWORD is still on the wire`);86info("purge it: printf \"protocol=https\\nhost=bitbucket.org\\n\\n\" | git credential reject");87 failures++;88}89}9091// ---------------------------------------------------------------- 2. REST92console.log(`\n[2] REST auth as "${REST_USER}" (token ${redact(TOKEN)})`);93const me =awaitapi("/user",REST_USER,TOKEN);94if(me.status===200){95pass(`authenticated as ${me.body?.display_name ??"?"} (${me.body?.nickname ??"?"})`);96}elseif(me.status===401){97fail("401 Unauthorized — wrong username for this credential type, a bad/expired token, or an app password during a brownout");98info(`an API token needs your Atlassian ACCOUNT EMAIL here, not your Bitbucket username`);99 failures++;100}elseif(me.status===403){101fail("403 Forbidden — the credential authenticated but is missing read:account or read:user:bitbucket");102 failures++;103}else{104fail(`unexpected HTTP ${me.status}`);105 failures++;106}107108// ---------------------------------------------------------------- 3. repo109console.log(`\n[3] Repository read: ${REPO}`);110const repo =awaitapi(`/repositories/${REPO}`,REST_USER,TOKEN);111if(repo.status===200){112pass(`can read ${repo.body?.full_name} (${repo.body?.is_private ?"private":"public"})`);113}elseif(repo.status===403|| repo.status===404){114fail(`HTTP ${repo.status} — "may not have access / no longer exists"; check the token SCOPES first, though this status has other causes too`);115info("clone needs read:repository:bitbucket; push also needs write:repository:bitbucket");116 failures++;117}elseif(repo.status===401){118fail("401 — the credential itself was rejected; fix check [2] first");119 failures++;120}else{121fail(`unexpected HTTP ${repo.status}`);122 failures++;123}124125// ---------------------------------------------------------------- 4. git126console.log(`\n[4] Git over HTTPS as "${GIT_USER}"`);127const url =`https://${encodeURIComponent(GIT_USER)}:${encodeURIComponent(TOKEN)}@bitbucket.org/${REPO}.git`;128try{129const{ stdout }=awaitexec("git",["ls-remote","--heads", url],{130timeout:30000,131// Never let a helper or a prompt answer for us — we are testing THIS credential.132env:{...process.env,GIT_TERMINAL_PROMPT:"0",GIT_CONFIG_NOSYSTEM:"1"},133});134const n = stdout.trim().split("\n").filter(Boolean).length;135pass(`clone/fetch works — ${n} branch${n ===1?"":"es"} visible`);136}catch(e){137const err =String(e.stderr?? e.message);138if(err.includes("CHANGE-3222")){139fail("CHANGE-3222 — an APP PASSWORD is being sent, not this token");140}elseif(err.includes("410")){141fail("HTTP 410 — app password rejected (brownout, or after 28 Jul 2026 permanently)");142}elseif(/403/.test(err)){143fail("HTTP 403 — authenticated but missing read:repository:bitbucket");144}elseif(/401|Authentication failed/.test(err)){145fail("401 — wrong git username for this credential type, or a bad token");146info(`API token -> x-bitbucket-api-token-auth | access token -> x-token-auth`);147}else{148fail(err.split("\n").filter(Boolean).slice(-2).join(" | "));149}150 failures++;151}152153console.log(154 failures ===0155?"\nAll checks passed. Safe to cut over.\n"156:`\n${failures} check(s) failed — do NOT cut over yet.\n`,157);158process.exit(failures ===0?0:1);
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" — a bare username means 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)
Success
Check [1] is the one worth internalising. git credential fill prints the exact username and password git would hand to Bitbucket, and the username identifies the credential type: a bare username means an app password, x-bitbucket-api-token-auth or an email means the token took. It is the only way to answer "which credential is actually being sent" without guessing.
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, and update the username alongside it. This is the step most teams half-do: they swap the secret and leave the username, 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. Check [1] should now pass rather than merely check [2].
Step 6 — Verify, honestly
Getting one green push is not verification. Confirm all four:
git credential fill returns a token-shaped username, not a bare one.
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 failure means you are still sending an app password. 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 for the API
401 on git, REST works
Wrong git username — your Bitbucket username or x-bitbucket-api-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: every branch of check [1] — it correctly flags a cached app password, passes a cached token, and reports an empty store after a purge; 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.
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 branch and the CHANGE-3222 branch are built from the documented responses rather than observed. They are the simplest paths in the script, but run it against a 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.