I Stopped Embedding the Token in the Remote URL. An Old Clone's .git/config Had It Sitting There, Readable by Anyone
How to audit unattended git jobs for tokens embedded in the remote URL and move them to http.extraheader. Full text of three scripts plus the real output from before and after the migration.
I was re-fetching a repository to hand to an agent when the usual routine stopped. One line was all it printed: detected dubious ownership.
The ownership mismatch itself is easy to deal with by moving to a place you can write, as I described in another article. What I want to write down here is something else I found while narrowing the cause down.
The short version first. If you embed an access token in the remote URL, the value stays in .git/config, a plain-text file. Depending on the umask in effect when the clone was made, other users on the same shared area may be able to read that file. My old clone was in exactly that state.
Below are the migration steps and the output I got when I ran them.
While Looking for the Cause, Something Else Turned Up
The old clone sat in a shared temporary area under a different user's name. This is what ls showed.
$ ls -ld /tmp/repos/antigravitylab.netdrwxr-xr-x 7 nobody nogroup 4096 Oct 26 04:56 /tmp/repos/antigravitylab.net$ stat -c '%U:%G %a %n' /tmp/repos/antigravitylab.net/.git/confignobody:nogroup 644 /tmp/repos/antigravitylab.net/.git/config
The owner was nobody, not my uid, so git status was bound to refuse. Here I reached for the usual workaround first.
git config --global --add safe.directory '*'
After that, git status worked and answered "Your branch is up to date." The next write failed, though.
error: could not lock config file .git/config: Permission deniedfatal: could not set 'remote.origin.url' to ...error: cannot open .git/FETCH_HEAD: Permission denied
safe.directory removes only Git's refusal. It does not change filesystem permissions. Reads pass and writes fail. The display turns green and the first write turns red, which makes it easy to misplace the cause when a job runs unattended.
That continues the earlier article. The rest is the real subject of this one.
To be honest, I came close to thinking "it ran, so it must be fine." A green display brings reassurance ahead of the evidence, and I only noticed that afterwards.
I Counted What Was in .git/config
If I could not rewrite it, I could at least check what it held. Reading is allowed. But I did not want any value in the terminal or in a log. So I wrote an audit script that never reads the value and only counts lines shaped like credentials.
#!/usr/bin/env bash# audit_remote_creds.sh <parent-dir>... list clones whose remote URL carries credentials, with the config permissions# Never prints the credential itself. Exit code: 0=none / 1=foundFOUND=0for ROOT in "$@"; do for CFG in "$ROOT"/*/.git/config; do [ -f "$CFG" ] || continue # Count only lines shaped like https://<something>@host (get a line count, never the value) N=$(grep -cE 'url *= *https://[^/@ ]+@' "$CFG") [ "$N" -gt 0 ] || continue MODE=$(stat -c '%a' "$CFG"); OWN=$(stat -c '%U' "$CFG") # Is the read bit (4) for 'others' set? if [ $((8#$MODE & 4)) -ne 0 ]; then WORLD="world-readable"; else WORLD="private"; fi echo "CRED $CFG owner=$OWN mode=$MODE $WORLD" FOUND=1 donedoneexit $FOUND
grep -c returns only the number of matching lines, so no value is printed. The lower three bits of the stat mode tell you whether other users can read the file.
One hit. mode=644 means readers other than the owner are allowed. As long as other users exist in that area, the token was readable. I have no sign that anyone read it, and no way to find out. So I decided not to say "it was not read", and to proceed on the assumption that it must be replaced.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You'll be able to check whether your clones' .git/config files hold credentials, in one command, without printing a single value
✦You'll be able to move existing clones from a URL-embedded token to http.extraheader without breaking them
✦You'll be able to decide what to do when you find an old clone you do not own and cannot rewrite: leave it alone, route around it, and rotate the token
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Instead of putting the token in the URL, I pass an authentication header per request. git -c http.extraheader=... is in effect only for that command and writes nothing to the config file.
First I checked that fetching really works, and that a bad value fails cleanly.
The fetch succeeded in 0.487 seconds, and the deliberately broken value stopped with a 400. After the run, .git/config contained 0 occurrences of the token string.
An Entry Script That Migrates Existing Clones Without Breaking Them
I folded the routine into one entry point that does three things: decide whether the existing clone may be reused, read the token from an environment variable and pass it as a header, and tighten the permissions on .git/config.
The judging half comes first. It looks at the owner and the remote before anything is written.
#!/usr/bin/env bash# repo_owner_preflight.sh <repo-path> <clone-url-without-token> [fallback-root]# Decide, before writing anything, whether an existing clone may be reused.# Exit code: 0=usable as is / 10=owned by someone else, clone into fallback / 20=broken, recreateset -uREPO="${1:?repo path}"; URL="${2:?url}"; FALLBACK="${3:-$HOME/repos}"ME=$(id -u)if [ ! -d "$REPO/.git" ]; then echo "NOCLONE $REPO"; exit 20fiOWNER=$(stat -c '%u' "$REPO/.git")if [ "$OWNER" != "$ME" ]; then # safe.directory silences git's refusal but does not make the dir writable. Split the cases here echo "FOREIGN owner_uid=$OWNER me=$ME -> use $FALLBACK/$(basename "$REPO")" exit 10fi# Even if it is mine, a locked config makes the remote swap failif [ ! -w "$REPO/.git/config" ] || [ -e "$REPO/.git/index.lock" ]; then echo "LOCKED $REPO/.git (config writable=$([ -w "$REPO/.git/config" ] && echo y || echo n))" exit 20fi# Reject a clone silently pointing at another repository (credentials stripped from compare and display)NOW=$(git -C "$REPO" remote get-url origin 2>/dev/null | sed -E 's#//[^@/]+@#//#')WANT=$(echo "$URL" | sed -E 's#//[^@/]+@#//#')[ "$NOW" = "$WANT" ] || { echo "REMOTE_MISMATCH now=$NOW want=$WANT"; exit 20; }echo "OK $REPO"; exit 0
Then the entry point.
#!/usr/bin/env bash# sync_repo.sh <owner/name> <preferred-dir> (the token is read from the GH_TOKEN environment variable)set -euo pipefailNAME="${1:?owner/name}"; PREFERRED="${2:?preferred dir}"URL="https://github.com/${NAME}.git"AUTH="Authorization: basic $(printf 'x-access-token:%s' "${GH_TOKEN:?GH_TOKEN is empty}" | base64 -w0)"HERE="$(dirname "$0")"set +e"$HERE/repo_owner_preflight.sh" "$PREFERRED" "$URL" "$HOME/repos"RC=$?set -ecase "$RC" in 0) DIR="$PREFERRED" ;; 10) DIR="$HOME/repos/$(basename "$PREFERRED")" ;; # owned by someone else: don't touch it, go to my own area *) if [ ! -d "$PREFERRED/.git" ]; then DIR="$PREFERRED" # just doesn't exist yet: create it there else DIR="$HOME/repos/$(basename "$PREFERRED")"; rm -rf "$DIR"; fi ;; # broken: recreate in my own areaesacumask 077 # keep the .git/config we create unreadable by othersmkdir -p "$(dirname "$DIR")"if [ -d "$DIR/.git" ] && [ "$(stat -c '%u' "$DIR/.git")" = "$(id -u)" ]; then git -C "$DIR" remote set-url origin "$URL" # drop any credential embedded earlier git -C "$DIR" -c http.extraheader="$AUTH" fetch --depth 1 origin main git -C "$DIR" reset --hard origin/main -qelse git -c http.extraheader="$AUTH" clone --depth 1 --branch main "$URL" "$DIR"fichmod 600 "$DIR/.git/config" # tighten permissions on existing clones tooecho "READY $DIR (preflight rc=$RC)"
umask 077 alone is not enough, because an existing clone's .git/config keeps the permissions it was created with. The final chmod 600 exists for that.
The Output I Actually Got
I ran it in four situations.
Situation
Preflight result
Entry script result
Clone owned by someone else
FOREIGN (exit 10)
Left untouched, moved to my own area. READY
Path that does not exist
NOCLONE (exit 20)
Cloned fresh at that path. READY
My own clone (token embedded earlier)
OK (exit 0)
URL cleaned, then fetched. READY
GH_TOKEN empty
-
Stops immediately with GH_TOKEN is empty
And the check after the migration.
$ stat -c '%a' $HOME/repos/antigravitylab.net/.git/config600$ grep -c 'ghp_\|github_pat_' $HOME/repos/antigravitylab.net/.git/config0
Four Pitfalls
The first was a false alarm from my own audit script. The first version compared strings in which the token had been masked with *** for display. One side was the URL and the other was the masked form, so even the same repository came out as REMOTE_MISMATCH. Masking is now for display only, and credentials are stripped before comparing. The full text above is the corrected version.
The second is not leaving safe.directory '*' in place. It allows every directory, so it quietly lets through exactly the kind of clone I had here, readable but not writable. Judging by the owner's uid stops you closer to the cause than a setting does.
The third is putting fetch before reset --hard. If you go on to reset after a failed fetch, you treat the old state as the latest. With set -e, a failed fetch becomes a failed entry script.
The fourth is how --depth 1 interacts with reading history. In a shallow clone, how far back git log can reach is limited. If your work needs history for diffs, revisit the --depth value.
What I Have Not Verified
Three things I have not checked by hand.
Whether another user on the shared area actually read this file. I have no way to find out.
How far an environment-variable token is inherited by child processes when agy runs unattended. Even with the header approach, where the variable lives becomes a new weak point.
Replacing the token that was in the old clone. I decided to treat it as something to be replaced from the moment I found it.
Next Steps
Three things you can do today.
Run audit_remote_creds.sh against the place where you keep your clones
Clean the URLs of any clones it finds and apply chmod 600
Do not leave a clone you cannot touch alone: replace the token that was written there
Now that the audit never puts a value on screen, opening the next morning's log is a little easier. As a one-person indie developer, I find that small relief worth the trouble.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.