← Journal
14 Jul 2026 · 11 min read

Security mistakes startups make in their first year

I have done security audits for fourteen startups in their first year of life. The same five mistakes show up every time. None of them require a security team to fix. They require an engineer who knows what the baseline looks like and has the discipline to enforce it before the first customer asks for a SOC 2.

I do a security audit for roughly one startup a month. The pattern is so consistent now that I can predict the findings before I look at the infrastructure. I am not talking about advanced persistent threats or zero-days. I am talking about the basics. The things that a junior security engineer would catch in thirty minutes. The things that get startups breached, lose them deals, and cost them their first enterprise customer before they knew they had one.

The startups I audit are not irresponsible. They are usually funded, have competent engineering teams, and care about security in the abstract. The problem is that security in the abstract does not protect you. Security in the specific does. And the specific baseline, the set of things that actually matters in your first year, is not long. It is five things. I find the same five missing in almost every audit.

Let me walk through them. Each one has a fix that takes hours, not weeks. None of them require a security team. They require an engineer who has seen the consequences of skipping them.

Mistake one: secrets in Git

Every single startup I have audited has had secrets in Git. Every one. Not always in the obvious way. Sometimes it is a .env file committed during the initial setup and never removed. Sometimes it is a database URL hardcoded in a config file that was copied from a tutorial. Sometimes it is an AWS access key in a shell history that got committed in a dotfiles repo. The most common version is a service account JSON key for GCP or AWS, committed to the repo because the engineer needed it locally and did not think about what would happen when it ended up on GitHub.

Here is the thing that founders do not understand about secrets in Git: it does not matter if the repo is private. Private repos get cloned by contractors, by new employees, by CI systems. The secret is now in places you cannot track. And if the repo ever becomes public, or if someone with access leaves and takes a copy, the secret is exposed. I have seen startups lose Stripe API keys this way. I have seen one lose their production database credentials.

The fix is not complex. Use a secrets manager. For a startup in its first year, the practical choices are:

  • External Secrets Operator with AWS Secrets Manager or GCP Secret Manager. This is what I install by default. Your application references a SecretStore CRD, the operator fetches the secret from the cloud provider at runtime, and the secret never touches Git. The Kubernetes Secret object is created automatically and kept in sync.
  • Sealed Secrets (Bitnami). Simpler if you do not want a cloud secrets manager yet. You encrypt the secret with a public key, commit the encrypted SealedSecret to Git, and the controller in the cluster decrypts it. The encrypted value is safe in Git. The private key lives only in the cluster.
# external-secrets-store.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: aws-secrets
  namespace: production
spec:
  provider:
    aws:
      service: SecretsManager
      region: eu-central-1
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets-sa
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: database-credentials
  namespace: production
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets
    kind: SecretStore
  target:
    name: db-creds
  data:
    - secretKey: password
      remoteRef:
        key: production/database/password

The second part of the fix is scanning. Add trufflehog or gitleaks to your CI pipeline so that any future secret that tries to enter the repo gets caught before merge.

# .github/workflows/security.yaml
name: secret-scan
on: [pull_request]
jobs:
  gitleaks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

I have seen this single change catch three secrets in the first week of running. Secrets that had been sitting in the repo for months, waiting for the wrong person to find them.

Mistake two: containers running as root

This one is universal. The default Dockerfile for most frameworks runs the application as root inside the container. Nobody changes it because the application works fine as root, and nobody has been bitten by it yet. Here is why it matters: if an attacker finds a remote code execution vulnerability in your application, they are now root inside the container. From root inside the container, they can access mounted secrets, they can install tooling, they can pivot to the network. If the container has any host paths mounted, they can reach the host. Running as a non-root user does not prevent exploitation, but it limits the blast radius. It turns a full compromise into a contained one.

The fix is a SecurityContext on every deployment, plus a PodSecurityPolicy or, in modern Kubernetes, a PodSecurity admission label on the namespace.

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        fsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: api
          image: ghcr.io/myorg/api:latest
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop:
                - ALL
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir: {}

And on the namespace:

apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted

The restricted Pod Security standard enforces non-root, no privilege escalation, all capabilities dropped, read-only root filesystem. This takes ten minutes per service to apply. I have never seen a startup where it took more than a day to fix across all services. And yet I have never audited a startup where it was already in place.

Mistake three: no network policies

Kubernetes, by default, allows every pod to talk to every other pod. Your frontend pod can reach your database pod. Your worker pod can reach your Redis pod. A compromised pod in the staging namespace can reach pods in the production namespace if they share a cluster. This is not a theoretical risk. It is the default network model, and most startups never change it.

The fix is NetworkPolicies. You do not need a service mesh or a CNI plugin with fancy features. You need the default-deny pattern plus explicit allow rules.

# default-deny.yaml - apply to every namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny
  namespace: production
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
---
# allow-api-to-db.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-to-db
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes:
    - Egress
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: postgres
      ports:
        - protocol: TCP
          port: 5432
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53

The default-deny policy drops all traffic. Then you add explicit allow rules for each connection that should exist. The API can reach the database on 5432. The API can reach kube-dns for name resolution. Everything else is blocked.

I applied this pattern at a startup in Berlin last year. They had six services in one namespace, all talking to each other freely. We found that the analytics worker, which had no reason to talk to the database, had been making queries against it every thirty seconds. Nobody knew why. The code that did it had been written by a contractor who left eight months earlier. The NetworkPolicy exposed it immediately. This is the secondary benefit of network policies: they make the implicit architecture explicit. You discover connections you did not know existed.

Mistake four: cluster-admin for everyone

The Kubernetes RBAC setup at most early-stage startups looks like this: every developer has cluster-admin. The founder has cluster-admin. The contractor who was hired for two weeks has cluster-admin. The CI service account has cluster-admin. Sometimes the break-glass account does not even exist because nobody has thought about what happens when the person with cluster-admin is unavailable.

Cluster-admin means root on the entire cluster. A developer with cluster-admin can read every secret in every namespace, delete every workload, and grant access to anyone else. There is no audit trail that means anything because everyone has the same god-level access.

The fix is role-based access, scoped per namespace, with a break-glass procedure.

# developer-role.yaml - scoped to a namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: developer
  namespace: production
rules:
  - apiGroups: [""]
    resources: ["pods", "pods/log", "services", "configmaps"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["pods/exec"]
    verbs: ["create"]
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: developers
  namespace: production
subjects:
  - kind: Group
    name: developers
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: developer
  apiGroup: rbac.authorization.k8s.io

Developers get read access to pods and logs, exec access for debugging, and the ability to restart deployments. They do not get access to secrets. They do not get access to other namespaces. They cannot delete the cluster.

The break-glass account is a separate credential, stored in a password manager or a physical safe, used only when the scoped access is insufficient. Every use of the break-glass account should be logged and reviewed. If someone uses it more than once a quarter, either your RBAC is too restrictive or your incident frequency is too high. Both are fixable.

Mistake five: no image scanning in CI

Startups build container images and deploy them without ever scanning for known vulnerabilities. The base image they use, usually node:18 or python:3.11-slim, has dozens of known CVEs at any given time. Some are negligible. Some are critical. The startup does not know which is which because nobody is checking.

I audited a startup in Lisbon that was running node:16 as their base image. Node 16 had been end-of-life for eleven months. The image had 142 known vulnerabilities, 9 of them critical. They had been deploying this image to production every week. Nobody had looked at the vulnerability report because nobody was generating one.

The fix is Trivy in CI, with a policy that fails the build on critical vulnerabilities.

# .github/workflows/trivy.yaml
name: container-scan
on:
  push:
    branches: [main]
jobs:
  trivy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        run: docker build -t app:scan .
      - name: Trivy scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: app:scan
          format: table
          exit-code: 1
          severity: CRITICAL,HIGH
          ignore-unfixed: true

exit-code: 1 means the pipeline fails if there are critical or high vulnerabilities with known fixes. ignore-unfixed: true means you do not fail on vulnerabilities that have no patch available, because failing on those just creates noise. The policy is: if there is a fix, you apply it. If there is not, you document it and accept the risk deliberately, not by accident.

Updating your base image regularly matters as much as scanning. Pin to a specific version, not latest, and set up Renovate or Dependabot to open PRs when newer versions are available. The combination of scanning and automated update PRs means your images stay current without anyone having to remember to do it.

What this baseline costs

I want to be concrete about the effort, because founders always ask. The five fixes I described above take the following time, in my experience:

  • Secrets management with External Secrets Operator: 4 hours, including IAM setup.
  • Non-root containers with Pod Security: 1 hour per service, assuming the Dockerfile is already reasonable.
  • Network policies with default-deny: 1 day for a cluster with 5 to 10 services, assuming you test carefully.
  • RBAC with scoped roles and break-glass: 4 hours, assuming you use OIDC integration with your identity provider.
  • Image scanning in CI: 2 hours, including tuning the severity thresholds.

Total: roughly three days of focused work for a cluster that is already running. Less if you are building from scratch and can apply the baseline as you go. This is not a security program. It is a baseline. The difference between a startup with this baseline and one without it is the difference between a startup that can pass a security review from an enterprise customer and one that cannot.

I have seen enterprise deals worth six figures stall because the customer’s security team found root containers and no network policies. The deal did not die. It went into a remediation cycle that took three months. The startup lost revenue, lost momentum, and lost credibility. The fixes took three days.

The pattern underneath all five

The pattern underneath all five mistakes is the same: security treated as a future task rather than a present baseline. Founders know they will need security eventually. They plan to get to it after the product is stable, after the round closes, after the first enterprise deal. The problem is that the consequences of not having the baseline arrive before the plan does. The secret leaks before you set up a secrets manager. The container gets exploited before you drop root. The enterprise customer asks for a security review before you have network policies.

The baseline I described is not a moving target. It is the same five things every time. It does not require a security engineer. It does not require a SOC 2 audit. It requires an engineer who knows what the baseline is and has the discipline to apply it before someone else discovers it is missing.

If you are a startup reading this and realizing your cluster has none of these five things in place, this is exactly the kind of baseline we build as part of Basecamp: digitalaultis.com/basecamp

The mistakes are predictable. So is the fix. The only question is whether you apply it before or after it costs you something.