Skip to main content

Vault JWT Authentication for Backstage Service Tokens

Overview

Backstage uses two types of JWT tokens:

  1. User Tokens - Issued when users log in via OAuth (GitHub, Google, etc.)
  2. Service Tokens - Issued for backend-to-backend communication

The terraform-state-backend plugin needs to use service tokens to authenticate to Vault.

Token Structure Differences

User JWT Token Structure

{
"header": {
"alg": "ES256",
"typ": "JWT",
"kid": "abc123"
},
"payload": {
"sub": "user:default/john.doe",
"ent": ["user:default/john.doe"],
"aud": "backstage",
"iss": "http://localhost:7007",
"iat": 1700000000,
"exp": 1700003600,
"groups": ["team-a", "team-b"],
"username": "john.doe"
}
}

Service JWT Token Structure

{
"header": {
"alg": "ES256",
"typ": "JWT",
"kid": "abc123"
},
"payload": {
"sub": "backstage-backend",
"aud": ["backstage"],
"iss": "http://localhost:7007",
"iat": 1700000000,
"exp": 1700003600,
"obo": "backstage-backend",
"target": "plugin:catalog"
}
}

Key Differences

ClaimUser TokenService Token
subuser:default/usernamebackstage-backend
aud"backstage" (string)["backstage"] (array)
groupsUser's groups❌ Not present
usernameUser's username❌ Not present
obo❌ Not presentbackstage-backend
target❌ Not presentTarget plugin ID

The Problem

Vault's JWT auth backend was configured for user tokens and fails when validating service tokens because:

  1. Missing claims: Service tokens don't have groups or username claims
  2. Different subject format: backstage-backend vs user:default/username
  3. Signature verification: Backstage may sign service tokens differently than user tokens

Solution 1: Update Vault Configuration (Advanced)

Run the provided configuration script:

cd /Users/liam.helmer/repos/badal-io/repo-devex-backstage/backstage/vault
chmod +x configure-service-token-auth.sh
./configure-service-token-auth.sh

This script:

  • Updates JWT auth config to accept service tokens
  • Adds clock skew leeway for timing issues
  • Configures the backstage-backend role properly
  • Sets bound_subject="backstage-backend" to match service tokens

Manual Configuration

If you prefer manual configuration:

# Update JWT auth backend
vault write auth/jwt/config \
jwks_url="http://localhost:7007/api/auth/.well-known/jwks.json" \
bound_issuer="http://localhost:7007" \
default_role="backstage-backend"

# Update backstage-backend role
vault write auth/jwt/role/backstage-backend \
bound_audiences="backstage" \
bound_subject="backstage-backend" \
user_claim="sub" \
role_type="jwt" \
policies="backend-service" \
ttl=1h \
max_ttl=24h \
clock_skew_leeway=60s \
expiration_leeway=60s \
not_before_leeway=60s

For local development, it's simpler to use a direct Terraform Cloud API token:

Step 1: Get a TFC Token

  1. Log in to Terraform Cloud: https://app.terraform.io
  2. Go to User Settings → Tokens
  3. Create a new token named "Backstage Local Dev"
  4. Copy the token value

Step 2: Update app-config.local.yaml

catalog:
providers:
terraformState:
tfc-badal-devex:
type: 'tfc'
organization: 'Badal_devex'
token: 'your-tfc-token-here' # ← Add your token here
environment: 'production'
schedule:
frequency: { minutes: 30 }
timeout: { minutes: 10 }
rateLimit:
maxConcurrent: 25
retryAfterMs: 2000

Step 3: Restart Backend

yarn start

The plugin will use the direct token and bypass Vault entirely.

Production Setup

For production, use Vault with proper JWKS configuration:

  1. Ensure JWKS endpoint is accessible from Vault:

    curl http://localhost:7007/api/auth/.well-known/jwks.json
  2. Verify token signing keys match between Backstage and Vault

  3. Configure Vault JWT auth with the correct JWKS URL and issuer

  4. Test authentication before deploying

Troubleshooting

Error: "failed to verify id token signature"

Cause: Vault cannot verify the JWT signature using the JWKS endpoint

Solutions:

  • Verify JWKS endpoint is accessible: curl http://localhost:7007/api/auth/.well-known/jwks.json
  • Check Vault logs: vault audit list and review audit logs
  • Try increasing clock skew leeway in Vault role configuration
  • Use direct TFC tokens for local development

Error: "No token found for organization"

Cause: Token is not stored in Vault at the expected path

Solution:

vault kv put backstage/backend/data/terraform-state-plugin/tfc-token \
Badal_devex="your-tfc-token-here"

Error: "Auth service not available"

Cause: AuthService not properly injected into VaultTokenService

Solution: Verify the module configuration includes:

deps: {
auth: coreServices.auth,
// ... other deps
}

References

Token Claim Details

Service Token Claims Explained

  • sub (Subject): Always "backstage-backend" for service tokens
  • aud (Audience): Array ["backstage"] indicating the token's intended audience
  • iss (Issuer): The Backstage instance URL that issued the token
  • iat (Issued At): Unix timestamp when token was created
  • exp (Expiration): Unix timestamp when token expires (typically 1 hour)
  • obo (On Behalf Of): The principal making the request (same as sub for service calls)
  • target (Target Plugin): The plugin ID that the token is requesting access to

Vault Role Binding

Vault's backstage-backend role uses these bindings:

bound_subject = "backstage-backend"  # Must match service token's "sub" claim
bound_audiences = "backstage" # Must match service token's "aud" claim
user_claim = "sub" # Use "sub" claim as the username in Vault

When a service token is presented to Vault:

  1. Vault verifies the signature using JWKS
  2. Vault checks aud contains "backstage"
  3. Vault checks sub equals "backstage-backend"
  4. Vault grants backend-service policy permissions

Next Steps

Choose one of the solutions above:

  • For Local Development: Use Solution 2 (direct TFC tokens) ← Recommended
  • For Production: Use Solution 1 (configure Vault properly)

The terraform-state-backend plugin is fully implemented with JWT authentication support. Once you configure either Vault or use direct tokens, the plugin will work correctly.