DEV Community

Constanza Diaz
Constanza Diaz

Posted on

Security by Design: Keeping API Tokens Out of Git with a 3-Layer Setup

When you build a product whose entire reason to exist is safety, security can't be something you bolt on later. It has to be a default — baked into the workflow from day one.

So before any application code, I set up how my app handles secrets. This post walks through that setup: a deliberate, three-layer approach that makes it structurally impossible for a token to end up in version control.

The star of the show is a Git pre-commit hook. I'll explain it from scratch.


Defense in depth

No single control should be the only thing standing between you and a leak. Three layers, each catching what the previous one might miss:

  1. .gitignore — prevention: keep secret-bearing files out of Git entirely
  2. A pre-commit hook — detection: scan every commit for secrets and block it if one slips through
  3. Environment variables — design: keep secrets out of the codebase in the first place

Layer 1 — .gitignore

# Environment variables
.env
.env.*
!.env.example
Enter fullscreen mode Exit fullscreen mode

The !.env.example exception keeps a template in the repo — a documented list of which variables are needed, with empty values. Anyone picking up the project knows exactly what to fill in without ever seeing a secret.


Layer 2 — the pre-commit hook

A Git hook is a script Git runs automatically at a specific moment. A pre-commit hook runs right before a commit is created:

git commit
    │
    ▼
pre-commit hook runs   ← automatic
    │
    ├─ secret found?  → ❌ commit blocked
    └─ all clean?     → ✅ commit proceeds
Enter fullscreen mode Exit fullscreen mode

Mine scans staged files for patterns that match real credentials — Atlassian tokens, AWS keys, private keys. If it finds one, the commit is blocked:

#!/usr/bin/env bash
set -euo pipefail

files=$(git diff --cached --name-only --diff-filter=ACM)
[ -z "$files" ] && exit 0

patterns='ATATT[A-Za-z0-9_=-]{16,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY-----'

found=0
while IFS= read -r f; do
  [ -n "$f" ] || continue
  content=$(git show ":$f" 2>/dev/null || true)
  if printf '%s' "$content" | grep -nEq "$patterns"; then
    echo "❌ Possible secret in: $f"
    found=1
  fi
done <<< "$files"

if [ "$found" -ne 0 ]; then
  echo "🛑 Commit blocked: secrets detected in staged files."
  exit 1
fi
Enter fullscreen mode Exit fullscreen mode

Two things worth knowing:

The exit code is everything. If a pre-commit hook exits non-zero, Git aborts the commit. That single exit 1 is what makes this real.

I keep the hook in the repo. Git's default hooks live in .git/hooks/, which isn't versioned. I store mine in a tracked .githooks/ folder and point Git at it:

git config core.hooksPath .githooks
Enter fullscreen mode Exit fullscreen mode

Now the hook travels with the project and gets reviewed like any other code.


Layer 3 — environment variables

The first two layers stop secrets from being committed. The third makes sure they're not in the code to begin with:

const CONFIG = {
  apiToken: process.env.JIRA_API_TOKEN,
}

if (!CONFIG.apiToken) {
  console.error("Missing JIRA_API_TOKEN. Run with: node --env-file=.env.local script.js")
  process.exit(1)
}
Enter fullscreen mode Exit fullscreen mode

On Node 20.6+, no dotenv dependency needed — the built-in --env-file flag loads your .env.local:

node --env-file=.env.local script.js
Enter fullscreen mode Exit fullscreen mode

Testing it

The best way to trust a safety net is to test it:

echo 'const token = "ATATT3xFAKEFAKEFAKEFAKEFAKE123456"' > leak-test.js
git add leak-test.js
git commit -m "test"
# ❌ Possible secret in: leak-test.js
# 🛑 Commit blocked: secrets detected in staged files.
Enter fullscreen mode Exit fullscreen mode

The commit never happens. That's the point.


Takeaway

Security works best when the tooling enforces the rules — not your memory. Three small pieces of configuration and a whole category of mistakes simply can't happen.

For HandyFEM, where trust is the product, this wasn't over-engineering. It was the starting line.


📚 HandyFEM App Series

🔗 Previous: From Specs to Tickets: Automating Jira Setup with Node.js and the Jira API

🔗 Next: none (latest post)

🏷️ All posts in this series: #HandyFEMApp

*Follow the build: #HandyFEMApp *

Top comments (0)