Backstage Security Hardening: Locking Down the Developer Portal

Backstage Security Hardening: Locking Down the Developer Portal

Why Backstage Is a High-Value Target

Backstage is a developer portal framework built by Spotify and donated to the CNCF. It aggregates service catalog metadata, Kubernetes cluster state, CI/CD pipeline status, infrastructure cost data, TechDocs, and software templates into a single interface. That aggregation is precisely why it is dangerous when misconfigured: a single compromised or publicly exposed Backstage instance gives an attacker a complete map of your internal infrastructure, live credential access to Kubernetes clusters, and the ability to run Scaffolder templates that provision new cloud resources or inject code into repositories.

The attack surface is wide. Backstage’s backend exposes a REST API used by the frontend and by backend-to-backend integrations. Plugins extend both the frontend and backend, each introducing new API routes. Out-of-the-box configuration defaults are designed for rapid local development and do not enforce authentication — those defaults regularly ship to production unchanged. The internal developer platform security article covers the broader IDP threat model; this article focuses on the specific controls required to harden a Backstage deployment.


Exposure Risks in Default Backstage Deployments

Guest mode in production. Backstage ships with a guest authentication provider enabled by default. When active, any user who reaches the frontend can access the portal as an anonymous guest with read access to the entire catalog. The catalog API (/api/catalog/entities) returns all registered components, systems, APIs, resources, and their metadata — including annotations that frequently contain AWS account IDs, cluster names, internal hostnames, and database connection strings that developers added as convenience references.

Unauthenticated catalog API. The backend catalog API does not require authentication tokens unless the permission framework is explicitly configured and enforced. API calls to /api/catalog/entities?filter=kind=Resource will enumerate all infrastructure resources registered in the catalog. Calling /api/catalog/entities?filter=kind=Component returns every service with its owner, lifecycle stage, and all attached metadata annotations.

Plugin API endpoints. Each backend plugin registers its own routes under the Backstage backend router. The Kubernetes plugin exposes /api/kubernetes/clusters and per-cluster proxy endpoints. The scaffolder plugin exposes /api/scaffolder/v2/tasks which lists all template execution tasks including their input parameters and step logs. The TechDocs plugin serves documentation content. None of these routes are individually authenticated by default — access control depends entirely on the global authentication middleware being correctly configured and applied to every plugin’s router.


Authentication Hardening

Disable Guest Access

Remove the guest provider from your app configuration entirely:

# app-config.yaml
auth:
  environment: production
  providers:
    github:
      production:
        clientId: ${AUTH_GITHUB_CLIENT_ID}
        clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}

The guest provider must not appear under providers in production configuration. Backstage checks for a guest key; if it is present, the frontend will offer it as a sign-in option regardless of what other providers are configured.

GitHub OAuth

auth:
  environment: production
  providers:
    github:
      production:
        clientId: ${AUTH_GITHUB_CLIENT_ID}
        clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}
        signIn:
          resolvers:
            - resolver: usernameMatchingUserEntityName

The signIn.resolvers block controls how GitHub identities map to Backstage user entities. Using usernameMatchingUserEntityName means only users who exist in the Backstage user catalog can sign in — it acts as an allowlist. Users not present in the catalog are rejected at login. Populate the user catalog from your GitHub organization using the GitHub org discovery provider.

OIDC Provider

For organisations using an enterprise identity provider (Okta, Azure AD, Google Workspace):

auth:
  environment: production
  providers:
    oidc:
      production:
        metadataUrl: ${AUTH_OIDC_METADATA_URL}
        clientId: ${AUTH_OIDC_CLIENT_ID}
        clientSecret: ${AUTH_OIDC_CLIENT_SECRET}
        prompt: auto
        signIn:
          resolvers:
            - resolver: emailMatchingUserEntityProfileEmail

Set prompt: auto rather than prompt: none. The none value suppresses the OIDC consent/login screen even when the user session is expired, which can allow stale session reuse.

Backend-to-Backend Token Rotation

Backstage backend plugins communicate with each other using static shared secrets defined in backend.auth. These tokens authenticate plugin-to-plugin API calls and must be rotated:

backend:
  auth:
    keys:
      - secret: ${BACKSTAGE_BACKEND_SECRET}

Generate this value with openssl rand -hex 32. Rotate it on the same schedule as other service-to-service credentials — 90 days maximum, sooner if any exposure is suspected. The secret must be provided as an environment variable from a secrets manager, never hardcoded in a configuration file checked into source control.


Plugin Security Review

Assessing Third-Party Plugins Before Installation

Backstage’s plugin ecosystem on npm contains hundreds of community plugins with varying levels of maintenance. Before installing any plugin:

  1. Inspect the plugin’s backend source code. Look for outbound HTTP calls that send data to external endpoints — telemetry, analytics, or “update check” requests that may include catalog metadata.
  2. Check the npm package’s maintainer history. Plugins that have changed hands or are unmaintained receive no security patches.
  3. Review what backend API routes the plugin registers. A plugin that registers a route without calling httpRouter.use(middleware.auth()) creates an unauthenticated endpoint in your backend.
  4. Check whether the plugin stores credentials. Backend plugins that integrate with third-party services frequently require API keys configured via integrations or dedicated config blocks — those credentials are in scope for your secrets management policy.

Run npm audit across your Backstage package after adding any plugin. Pin plugin versions in package.json rather than using range specifiers (^, ~) to prevent silent upgrades.

Backstage Permission Framework

The permission framework, introduced in Backstage 1.x, provides policy-based access control over catalog reads, scaffolder template execution, and TechDocs access. It is opt-in and disabled by default. Enable it:

permission:
  enabled: true

With the permission framework enabled, you must implement a PermissionPolicy class in your backend. An allow-by-default policy with no rules is not a hardened configuration — it is equivalent to no permission framework. A deny-by-default policy with explicit allow rules is the correct starting point:

class BackstagePermissionPolicy implements PermissionPolicy {
  async handle(
    request: PolicyQuery,
    user?: BackstageIdentityResponse,
  ): Promise<PolicyDecision> {
    if (isPermission(request.permission, catalogEntityDeletePermission)) {
      if (user?.identity.ownershipEntityRefs.includes('group:default/platform-engineering')) {
        return { result: AuthorizeResult.ALLOW };
      }
      return { result: AuthorizeResult.DENY };
    }
    return { result: AuthorizeResult.ALLOW };
  }
}

Restrict catalog delete operations, scaffolder template execution, and TechDocs publish operations to specific groups. Catalog reads are typically allowed broadly within authenticated users, but scaffolder execution — which can create repositories, provision cloud resources, and run arbitrary template steps — must be gated to groups that have completed onboarding and accepted terms of service.


Kubernetes Backend Plugin: Least-Privilege Configuration

The Backstage Kubernetes plugin aggregates cluster state across all registered clusters and displays it in the portal. By default, tutorials and quickstart guides configure it with a service account that has cluster-admin ClusterRole binding, which grants read and write access to every resource in every namespace. This is unnecessary and dangerous.

Create a dedicated ServiceAccount with read-only access scoped to the namespaces Backstage needs to display:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: backstage
  namespace: backstage
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: backstage-read-only
rules:
  - apiGroups: [""]
    resources: ["pods", "services", "configmaps", "limitranges", "resourcequotas"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]
    resources: ["deployments", "replicasets", "statefulsets", "daemonsets"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["autoscaling"]
    resources: ["horizontalpodautoscalers"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["networking.k8s.io"]
    resources: ["ingresses"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["batch"]
    resources: ["jobs", "cronjobs"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: backstage-read-only
subjects:
  - kind: ServiceAccount
    name: backstage
    namespace: backstage
roleRef:
  kind: ClusterRole
  name: backstage-read-only
  apiGroup: rbac.authorization.k8s.io

If you have multi-tenant clusters where Backstage should only show specific namespaces, use RoleBinding scoped to those namespaces rather than ClusterRoleBinding. The Kubernetes plugin supports per-cluster namespace filtering:

kubernetes:
  clusterLocatorMethods:
    - type: config
      clusters:
        - name: production
          url: ${K8S_CLUSTER_URL}
          authProvider: serviceAccount
          serviceAccountToken: ${K8S_SERVICE_ACCOUNT_TOKEN}
          skipTLSVerify: false
          namespaces:
            - payments
            - auth
            - platform

Set skipTLSVerify: false explicitly. It defaults to false but stating it explicitly prevents accidental override by environment-specific config files.


Secret Exposure Patterns

Credentials in catalog metadata. The catalog-info.yaml files that define Backstage components live in source repositories and are ingested by Backstage’s catalog provider. Developers frequently add annotations containing connection strings, API endpoints with embedded credentials, or internal token values as convenience metadata. Backstage renders these annotations in the component overview UI and returns them via the catalog API. Add catalog-info.yaml scanning to your secret detection pipeline (truffleHog, gitleaks) with rules targeting Backstage annotation keys.

TechDocs content. TechDocs renders Markdown documentation from source repositories. If a developer commits a TechDocs page containing a database password, an API key in a curl example, or an internal credential, that content is indexed, stored in the TechDocs storage backend (S3 or GCS), and served to all authenticated Backstage users. TechDocs content is not scanned for secrets by default. Apply the same pre-commit and CI secret scanning controls to TechDocs source directories that you apply to application source code.

Scaffolder step logs. The Backstage Scaffolder executes software templates step by step and streams log output to the task log, which is accessible via the Scaffolder UI and the /api/scaffolder/v2/tasks/<taskId>/eventstream endpoint. Template steps that run shell commands, call APIs, or interact with Git frequently emit log lines containing tokens, repository URLs with embedded credentials, or environment variable values. Review all Scaffolder template steps (action: shell:script, action: fetch:template) to ensure they do not log sensitive values. Use the env field in template steps carefully — any value passed through env may appear in step output.


Network Hardening

Backstage should never be directly reachable from the public internet. It is an internal tool with internal trust assumptions. Apply these controls at the network layer regardless of how well Backstage itself is configured — defense in depth requires that network controls exist independently of application controls, as described in the zero-trust architecture principles article.

# Kubernetes Ingress: restrict to internal load balancer annotation
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: backstage
  namespace: backstage
  annotations:
    kubernetes.io/ingress.class: "nginx"
    nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
    service.beta.kubernetes.io/aws-load-balancer-internal: "true"
spec:
  rules:
    - host: backstage.internal.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: backstage
                port:
                  number: 7007

Use an internal load balancer (aws-load-balancer-internal: "true" on AWS, equivalent annotations on GCP/Azure) to prevent the load balancer from acquiring a public IP. Restrict allowed source ranges to your VPN CIDR blocks and corporate network ranges.

If Backstage must be accessible to remote developers via VPN, place a WAF (AWS WAF, Cloudflare, or equivalent) between the VPN termination point and Backstage. Configure WAF rules to block:

  • Requests to /api/catalog/entities from source IPs outside the developer VPN range
  • Requests exceeding rate limits on the auth endpoints (/api/auth/) to limit credential stuffing
  • HTTP methods other than GET, POST, PUT, DELETE, OPTIONS on the API routes
  • Requests with path traversal sequences in the URL targeting TechDocs storage paths

Container Hardening

The official Backstage Docker image runs as root by default in many community configurations. Add a non-root user to your Dockerfile:

FROM node:20-bookworm-slim

RUN groupadd --gid 1001 backstage && \
    useradd --uid 1001 --gid backstage --shell /bin/bash --create-home backstage

WORKDIR /app
COPY --chown=backstage:backstage . .

USER backstage

EXPOSE 7007
CMD ["node", "packages/backend", "--config", "app-config.yaml"]

Apply a read-only root filesystem in the Kubernetes pod spec, with explicit writable volume mounts for the paths Backstage writes to at runtime:

securityContext:
  runAsNonRoot: true
  runAsUser: 1001
  runAsGroup: 1001
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false
  capabilities:
    drop:
      - ALL
resources:
  limits:
    cpu: "1"
    memory: "1Gi"
  requests:
    cpu: "250m"
    memory: "512Mi"
volumeMounts:
  - name: tmp
    mountPath: /tmp
  - name: techdocs-cache
    mountPath: /app/techdocs-cache
volumes:
  - name: tmp
    emptyDir: {}
  - name: techdocs-cache
    emptyDir: {}

Set explicit resources.limits on CPU and memory. Without limits, a single Backstage instance processing a large catalog or generating TechDocs can consume node-level resources and impact adjacent workloads.


Backstage RBAC: Permission Framework Configuration

The permission framework supports conditional decisions — decisions that depend on resource attributes rather than just the user’s identity. For catalog entities, conditions can restrict access based on entity ownership, namespace, or tag:

import {
  catalogConditions,
  createCatalogConditionalDecision,
} from '@backstage/plugin-catalog-backend/alpha';

class BackstagePermissionPolicy implements PermissionPolicy {
  async handle(
    request: PolicyQuery,
    user?: BackstageIdentityResponse,
  ): Promise<PolicyDecision> {
    if (isResourcePermission(request.permission, RESOURCE_TYPE_CATALOG_ENTITY)) {
      return createCatalogConditionalDecision(
        request.permission,
        catalogConditions.isEntityOwner({
          claims: user?.identity.ownershipEntityRefs ?? [],
        }),
      );
    }
    if (isPermission(request.permission, scaffolderTaskCreatePermission)) {
      const groups = user?.identity.ownershipEntityRefs ?? [];
      if (groups.includes('group:default/developers')) {
        return { result: AuthorizeResult.ALLOW };
      }
      return { result: AuthorizeResult.DENY };
    }
    return { result: AuthorizeResult.ALLOW };
  }
}

The isEntityOwner conditional grants catalog entity access only to the owning team or user, restricting cross-team visibility of sensitive internal services. Scaffolder template execution is restricted to members of group:default/developers.


Audit Logging

Backstage does not enable structured audit logging by default. The @backstage/plugin-audit-log-node package provides the audit log infrastructure used by backend plugins to emit standardised audit events.

Install and configure the audit logger in your backend:

import { createBackendModule } from '@backstage/backend-plugin-api';
import { auditLogServiceRef } from '@backstage/plugin-audit-log-node';

const auditLogModule = createBackendModule({
  pluginId: 'audit-log',
  moduleId: 'audit-log-module',
  register(reg) {
    reg.registerInit({
      deps: { auditLog: auditLogServiceRef },
      async init({ auditLog }) {
        // Audit log is now available to all plugins via DI
      },
    });
  },
});

Events to capture at minimum:

  • User authentication: sign-in, sign-out, failed sign-in, token refresh
  • Catalog mutations: entity registration, entity deletion, entity annotation modification
  • Scaffolder task creation and completion: template ID, actor identity, input parameters (with credential fields redacted), task outcome
  • Permission policy decisions: allow/deny outcomes with actor and resource identifiers
  • Kubernetes plugin access: which cluster, which namespace, which user

Configure your backend logger to emit JSON-structured output and ship it to your SIEM via a log aggregator (Fluentd, Vector, or a Kubernetes log collector). At minimum, structured logs should include timestamp, actorId, action, resourceType, resourceRef, outcome, and ip fields. Do not log raw request bodies from Scaffolder inputs without first applying a credential redaction filter — Scaffolder inputs routinely contain repository tokens and cloud provider credentials passed as template parameters.

Retain audit logs for a minimum of 90 days in hot storage and 12 months in cold storage. Backstage audit events are frequently required for incident investigation when catalog data or Scaffolder templates are implicated in a security incident.


Production Checklist

  • Guest auth provider absent from app-config.production.yaml
  • OIDC or GitHub OAuth configured with a resolver that enforces catalog membership
  • Backend shared secret rotated from default, sourced from secrets manager
  • Permission framework enabled with an explicit deny-default policy
  • Catalog API access restricted to authenticated users via permission conditions
  • Scaffolder execution restricted to an explicit group allowlist
  • Kubernetes plugin ServiceAccount using read-only ClusterRole, no cluster-admin binding
  • skipTLSVerify: false on all cluster definitions
  • Backstage ingress restricted to internal load balancer with VPN source CIDR allowlist
  • WAF in front of Backstage API endpoints
  • Container running as non-root with read-only root filesystem
  • CPU and memory limits set on the Backstage pod
  • Audit log plugin configured and shipping to SIEM
  • TechDocs source directories in scope for pre-commit secret scanning
  • Catalog-info.yaml files covered by secret detection CI step
  • Third-party plugins reviewed for unauthenticated route registration before installation
  • Plugin versions pinned in package.json