Vault JWT Authentication for Backstage Service Tokens
Overview
Backstage uses two types of JWT tokens:
- User Tokens - Issued when users log in via OAuth (GitHub, Google, etc.)
- 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
| Claim | User Token | Service Token |
|---|---|---|
| sub | user:default/username | backstage-backend |
| aud | "backstage" (string) | ["backstage"] (array) |
| groups | User's groups | ❌ Not present |
| username | User's username | ❌ Not present |
| obo | ❌ Not present | backstage-backend |
| target | ❌ Not present | Target plugin ID |
The Problem
Vault's JWT auth backend was configured for user tokens and fails when validating service tokens because:
- Missing claims: Service tokens don't have
groupsorusernameclaims - Different subject format:
backstage-backendvsuser:default/username - 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-backendrole 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
Solution 2: Use Direct TFC Tokens (Recommended for Local Development)
For local development, it's simpler to use a direct Terraform Cloud API token:
Step 1: Get a TFC Token
- Log in to Terraform Cloud: https://app.terraform.io
- Go to User Settings → Tokens
- Create a new token named "Backstage Local Dev"
- 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:
-
Ensure JWKS endpoint is accessible from Vault:
curl http://localhost:7007/api/auth/.well-known/jwks.json -
Verify token signing keys match between Backstage and Vault
-
Configure Vault JWT auth with the correct JWKS URL and issuer
-
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 listand 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
subfor 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:
- Vault verifies the signature using JWKS
- Vault checks
audcontains"backstage" - Vault checks
subequals"backstage-backend" - Vault grants
backend-servicepolicy 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.