What counts as a secret
Credentials, tokens, keys — and what is not one.
A concert ticket has your name printed across the top and a barcode down one side. The person on the door scans the barcode. They do not read the name, and they do not look at your face. A photo of that barcode on a stranger's phone opens the same door, at the same time, and the turnstile has no way to tell the two of you apart. The barcode holds all the power, and that power comes entirely from the fact that not everyone has it.
A secret works the same way. It is any value whose power comes from staying unknown: database passwords, API (application programming interface) tokens, cloud access keys, TLS (transport layer security) private keys, code signing keys, and session cookies that act as a pass. Security people call this the bearer property, which means the system trusts whoever bears the string and asks no second question. If presenting the value is enough to act as someone or something, it is a secret, whether or not your wiki calls it one. Teams blur this by labelling everything sensitive a secret. Customer records are sensitive and need real protection, but they are not credentials. A public TLS certificate is not a secret; the private key sitting beside it is. This course starts here because vague language produces vague controls, and before you pick HashiCorp Vault or a cloud provider's secret manager you need a shared vocabulary for what you are protecting, how it leaks, and what rotation actually means.
Three Words People Use Interchangeably
A credential is anything used to prove identity or permission. A secret is a credential whose entire protection is that nobody else knows it. A token is a machine-issued secret, usually random, usually carrying a recognisable prefix and often an expiry date: ghp_ for a classic GitHub personal access token (PAT), github_pat_ for the fine-grained kind, glpat- for GitLab, xoxb- for a Slack bot, hvs. for a HashiCorp Vault service token. Those prefixes are deliberate. Providers publish them so their own scanners, and GitHub's, can spot a leaked token in public code and kill it within minutes. The same prefixes make life easy for anyone running the same regular expressions over the same public repositories, which is why leak detection is a race rather than a safety net.
Prefix matching is the cheapest search there is, and you can run it over any checkout right now with tools that already ship on your machine.
# Recognisable prefixes are a clue, not a complete detector.# -R walks directories, -I skips binaries, -n prints line numbers.grep -RInE '(AKIA|ASIA)[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|hvs\.[A-Za-z0-9]+' . 2>/dev/null
./deploy/old-deploy.sh:2:export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE./docs/notes.md:1:Temp token for the migration: ghp_4kX9mQvTz7Jw2LhPbN6cRd8sYt1uV3wZ0aBc./docs/notes.md:2:Vault login worked with hvs.CAESIJ7xQ2mN4pRt8vWy1zA3bD5fG6hJ
Now look at what the search did not say. Line 3 of that deploy script reads AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY, and nothing matched it. That one missing line is the point of this lesson. AKIAIOSFODNN7EXAMPLE is an access key ID, an identifier that names which key is in use, and Amazon treats it as public information: it appears in the IAM console and gets stamped into CloudTrail, the service that records every API call made in your account. The forty characters on the next line are the actual secret, and they have no prefix, no fixed alphabet, and no shape a pattern can grip. They look like every other random string in your repository. So scanners chase the half that is easy to find and treat it as a signpost to the half that matters. A hit on AKIA means a key pair was written down somewhere, and the useful half is usually within a line or two. (ASIA marks a temporary credential issued by STS, Amazon's security token service; those come as a set of three, because they need a session token alongside the pair.)
The Bearer Test, Run Against A Real System
You find a forty-character string in a log file. Is it a secret? The dependable answer does not come from the variable name next to it, because names lie and get copied around. It comes from asking a system what the string can do. Most providers publish a cheap, read-only identity endpoint for exactly this purpose.
# Ask GitHub who this token is and what it is allowed to touch.# -o /dev/null throws away the body; -D - dumps the response headers.curl -s -o /dev/null -D - \-H "Authorization: Bearer $FOUND_TOKEN" \https://api.github.com/user \| grep -iE '^HTTP|^x-oauth-scopes|^x-ratelimit-limit'
HTTP/2 200x-oauth-scopes: repo, workflow, read:orgx-ratelimit-limit: 5000
Three lines, and the mystery is over. HTTP/2 200 means the string is live. x-oauth-scopes is the blast radius, spelled out by the provider itself: repo reaches every private repository the owner can see, and workflow lets the holder edit GitHub Actions files, which is a direct route to every other secret in the pipeline. A dead or invented token answers HTTP/2 401 and reports a rate limit of 60, the anonymous allowance, which is a useful tell on its own. Fine-grained personal access tokens return no x-oauth-scopes header at all, because their permissions are recorded per repository instead; for those you read the token's own page in the GitHub settings screen. Cloud keys have the same kind of endpoint.
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE \AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \AWS_DEFAULT_REGION=us-east-1 \aws sts get-caller-identity
{"UserId": "AIDA2XVJ7QK4EXAMPLEID","Account": "123456789012","Arn": "arn:aws:iam::123456789012:user/ci-deployer"}
That turns an anonymous string into a named identity inside a named account. user/ci-deployer in the production account is a very different Monday morning from a read-only user in a sandbox, and you now know which one you are having. IAM, Amazon's identity and access management service, will also show you the date that key was last used, which tells you whether somebody else has already been driving it.
aws sts get-caller-identity, GET /user, vault token lookup), run it from a machine you are supposed to be using, and write down that you did it and when. Do not go browsing data with a credential you found, and do not test one belonging to an organisation you do not work for. Whatever the answer turns out to be, rotate the credential anyway: a key that reached a log file has to be treated as copied.The Certificate Is Public, The Key Is Not
A signet ring presses a seal into hot wax. Everyone who receives the letter studies the seal and compares it against the last one they were sent. Nobody except the owner ever holds the ring. A TLS certificate is the seal and the private key is the ring, and the two get confused constantly because they arrive as a matching pair of files with almost the same name.
# Generate a throwaway pair. -noenc means "leave the key unencrypted"# (OpenSSL 1.x spells that flag -nodes). Progress noise goes to /dev/null.openssl req -x509 -newkey rsa:2048 -noenc -days 365 \-keyout server.key -out server.crt \-subj "/CN=api.example.com/O=Example Ltd" 2>/dev/null# Everything a certificate holds is meant to be read by strangersopenssl x509 -in server.crt -noout -subject -issuer -dates
subject=CN=api.example.com, O=Example Ltdissuer=CN=api.example.com, O=Example LtdnotBefore=Jul 27 16:24:05 2026 GMTnotAfter=Jul 27 16:24:05 2027 GMT
Your browser downloads that certificate from every site it visits, before any login, and certificate transparency logs publish a copy of every certificate a publicly trusted authority issues. Guarding it is theatre. The private key is the other half, and the relationship between the two runs in one direction only. RSA, the maths behind this particular pair, produces two matching numbers where one can be handed out and the other cannot be worked backwards from it.
# The public half can always be pulled back out of the private keyopenssl rsa -in server.key -pubout > from-key.pubopenssl x509 -in server.crt -noout -pubkey > from-crt.pubdiff from-key.pub from-crt.pub && echo "IDENTICAL"# The reverse trip does not existopenssl rsa -in server.crt -check
writing RSA keyIDENTICALCould not find private key from server.crt
The certificate carries the public key, so anyone can extract it and nobody minds. The private key file contains both halves, which is why the first command succeeds and the second one finds nothing to work with. Here is what breaks when that key file leaks: whoever holds it can stand up a server anywhere on earth that proves, to every browser, that it is api.example.com. Fixing that is slower than fixing a password. You generate a new key, get a fresh certificate issued, deploy it everywhere the old one lived, and ask the authority to revoke the old certificate, knowing that browsers check revocation inconsistently and some skip the check entirely when the network is slow. Budget hours for a TLS key rotation, not minutes. The certificate signing request (CSR) you sent to obtain the certificate is public too, since it holds the public key and your organisation details and nothing else.
Tokens That Show You Their Own Contents
A hotel keycard usually has the room number printed on the front. Knowing the number does not open the door, and the number being visible does not make the card safe to leave on a bar. A JSON Web Token (JWT, where JSON is JavaScript Object Notation, a plain-text way of writing structured data) behaves like that card. Its middle section is an open description of what the token is for, encoded rather than encrypted, and you can read it with software already on your laptop.
TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdmMtYmlsbGluZyIsInNjb3BlIjoiaW52b2ljZXM6d3JpdGUiLCJpc3MiOiJhdXRoLmludGVybmFsIiwiZXhwIjoxNzkzNDA0ODAwfQ.Kx7hV2sQ9pL4mNbR8tYw3zC6dF1gJ0aE5uH7iO2kP9M'# JWTs use the base64url alphabet and drop the '=' padding.# Swap the alphabet back, then pad up to a multiple of 4,# because base64 -d rejects anything shorter with "invalid input".PAYLOAD=$(printf '%s' "$TOKEN" | cut -d. -f2 | tr '_-' '/+')while [ $(( ${#PAYLOAD} % 4 )) -ne 0 ]; do PAYLOAD="$PAYLOAD="; doneprintf '%s' "$PAYLOAD" | base64 -d; echodate -u -d @1793404800 '+%Y-%m-%d %H:%M:%S UTC'
{"sub":"svc-billing","scope":"invoices:write","iss":"auth.internal","exp":1793404800}2026-10-31 00:00:00 UTC
Three facts fall out of a few lines of shell. The token acts as svc-billing. It is allowed to write invoices. It stops working on 31 October. None of that needed the signing key, because the payload was never confidential; the signature on the end is what the server checks, and only the issuer can produce a valid one. Readable does not mean harmless. Anyone who copies that string out of a log becomes svc-billing until it expires. A short lifetime shrinks the window and is worth doing, but a fifteen-minute token sitting in a log that your company keeps for a year is a fifteen-minute window that anybody can open the moment they read the log.
The same care extends to values that mint other values. A refresh token, part of OAuth (open authorisation, the standard behind most "sign in with" buttons), exists to produce fresh access tokens, so it usually outlives everything it issues. Multi-factor backup codes bypass the second factor by design. An encryption passphrase protects every file its key ever touched. The classic miss during an incident is rotating the access token, closing the ticket, and leaving a refresh token alive inside a mobile app, quietly handing new access tokens to whoever holds it. Inventory the things that produce secrets alongside the secrets themselves.
Sensitive Data Is Not A Credential
A customer's email address and your database password both need protecting, and they need completely different plans. A password is replaceable. If it leaks you issue a new one, and every copy an attacker made stops working within minutes. You cannot reissue somebody's date of birth. A leak of personal data (PII, personally identifiable information) is permanent, and the response is legal and organisational rather than technical: establish who was affected, notify them, and meet whatever deadline your regulator sets.
The controls follow from that difference. Credentials get a manager, a rotation schedule, an expiry, and a revocation path you have actually tested. Personal data gets collection limits, retention limits, encryption at rest, and access logging, and rotation means nothing to it. Blurring the two produces the worst of both. Call everything a secret and the rotation policy becomes impossible to enforce, so it gets quietly ignored everywhere. Call nothing a secret and credentials end up in the same table as the mailing list. Classify by what a value does, not by how alarming it feels to lose. A database dump usually contains both kinds, so it inherits both playbooks at once.
What People Mislabel
Four beliefs cause most of the damage this course exists to prevent, and the first is that a Kubernetes Secret object is encrypted. It is base64 encoded, which is a way of moving arbitrary bytes safely through channels that only carry text. That is a shipping container, not a lock.
kubectl create secret generic db-creds --from-literal=password='S3cr3t-Pa55w0rd!'# What the API server storeskubectl get secret db-creds -o jsonpath='{.data.password}'; echo# What anyone who can read it gets back, with no key of any kindkubectl get secret db-creds -o jsonpath='{.data.password}' | base64 -d; echo
secret/db-creds createdUzNjcjN0LVBhNTV3MHJkIQ==S3cr3t-Pa55w0rd!
No key, no passphrase, nothing beyond permission to read the object. Anyone whose RBAC (role-based access control) rules let them get that Secret reads the password, and so does anyone who reaches etcd, the key-value database behind the cluster, from a disk image or an old backup. Encryption at rest for Secrets comes from an EncryptionConfiguration file that a cluster administrator wires into the API server, often pointing at a cloud key management service. Several managed platforms set that up for you; a cluster somebody built by hand almost never has it. Check rather than assume. The other three beliefs fail the same way. A private git repository is access-controlled rather than encrypted, and it goes public the day someone forks it, the day a contractor keeps their laptop, or the day a visibility toggle gets flipped. Environment variables are visible in /proc/<pid>/environ to root and to anything running as the same user, they get printed by crash handlers, and they get dumped by any service that logs its own configuration at startup. And a value being long and random protects nobody; it only means no one will guess it, which was never the way these get stolen.
Write The One-Page Standard
Vocabulary that lives in one engineer's head is not a control. Write down the classes your organisation recognises, where each may live, how long it may live, and where it must never appear. One page that gets checked during code review beats a policy document nobody opens.
# Every value an app reads at boot belongs to exactly one class.# Reviewers check this file; scanners enforce the forbidden_in lists.classes:- name: static-credentialexamples: [database password, SMTP password, third-party API key]store: aws-secretsmanagermax_age_days: 90forbidden_in: [git, container image, CI log, chat, ticket comments]- name: short-lived-tokenexamples: [CI cloud session via OIDC, Vault service token, STS session]store: issued at runtime, never written to diskmax_age_minutes: 60forbidden_in: [git, container image, CI log]- name: signing-keyexamples: [TLS private key, code signing key, JWT signing key]store: kms-or-hsm # key material never leaves the boundarymax_age_days: 365forbidden_in: [git, container image, CI log, developer laptop]- name: sensitive-not-a-credentialexamples: [customer email, date of birth, support transcript]store: application database, encrypted at restcontrol: retention limit + access logging # rotation does not apply- name: publicexamples: [TLS certificate, Stripe publishable key, client_id, account id]store: anywherecontrol: none
The forbidden_in lists earn their keep because a reviewer and a scanner can both check them mechanically. The store values name real systems: AWS Secrets Manager for static credentials such as the SMTP password your app uses to send mail (SMTP is the simple mail transfer protocol), a KMS (key management service) or HSM (hardware security module) for signing keys so the key material never leaves that boundary, and short sessions issued at runtime through OIDC (OpenID Connect, a thin identity layer on top of OAuth) for continuous integration, so there is no long-lived cloud key sitting in the pipeline waiting to leak. Skip the written standard and every team invents a weaker one privately, and you find out which teams during an incident.
Try This
Open the application you know best and list every value it reads at boot. Say the class of each one out loud, then note where that value lives today. The gap between those two columns is your backlog for the rest of this course.
printf '%s\n' \'DATABASE_URL -> secret (contains the password)' \'STRIPE_SECRET_KEY -> secret (server-side, can move money)' \'STRIPE_PUBLISHABLE_KEY -> public (ships to browsers)' \'TLS_CERT_PEM -> public (served to every visitor)' \'TLS_KEY_PEM -> secret (proves you are the site)' \'CUSTOMER_EMAIL -> sensitive data, not a credential'
DATABASE_URL -> secret (contains the password)STRIPE_SECRET_KEY -> secret (server-side, can move money)STRIPE_PUBLISHABLE_KEY -> public (ships to browsers)TLS_CERT_PEM -> public (served to every visitor)TLS_KEY_PEM -> secret (proves you are the site)CUSTOMER_EMAIL -> sensitive data, not a credential
Most of those values live in more places than your list admits. The next lesson follows one credential through the systems that copy it without being asked: shell history, process arguments, CI logs, container image layers, and the crash reporter that helpfully attaches your entire environment to a stack trace.
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE in a deploy script but prints nothing for the next line, AWS_SECRET_ACCESS_KEY=wJalr.... What is the right read of that result?AKIA is a signpost to the forty random characters beside it; confirm the identity with a read-only call such as aws sts get-caller-identity if you need to, then rotate the pair and clean up the file afterwards.Takeaway
The trap worth remembering here: testing a found credential is itself an action. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.