Files
sentryagent-idp/docs/devops/security.md
SentryAgent.ai Developer d94a8cedc0 docs: DevOps documentation — complete docs/devops/ set
Adds the full devops-documentation OpenSpec change implementation.
Separate from docs/developers/ — serves a different audience (operators,
not API consumers).

docs/devops/:
- README.md          — index and system overview
- architecture.md    — components, ports, data flow, Redis key patterns
- environment-variables.md — all 7 env vars (required + optional, formats, .env example)
- database.md        — 4-table schema, indexes, constraints, migration runner
- local-development.md — docker-compose setup, health checks, startup, Dockerfile gap noted
- security.md        — RSA key generation/rotation, CORS, bcrypt, secret storage guidance
- operations.md      — startup order, graceful shutdown, log reference, troubleshooting

QA gates: 48/48 tasks complete. All env vars verified against source.
All table names verified against migrations. All ports verified against
docker-compose.yml. All internal links resolve.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 14:28:55 +00:00

5.2 KiB

Security

Security configuration for AgentIdP — JWT key management, CORS, and secret storage.


JWT Key Management

AgentIdP uses RS256 (RSA + SHA-256) to sign and verify JWT access tokens. This asymmetric scheme means:

  • The private key signs tokens — must be kept secret, known only to the server
  • The public key verifies tokens — can be shared with any system that needs to validate tokens

Generate a keypair

Generate a 2048-bit RSA keypair:

# Generate private key
openssl genrsa -out private.pem 2048

# Extract public key
openssl rsa -in private.pem -pubout -out public.pem

Verify the files:

# Confirm private key is valid RSA
openssl rsa -in private.pem -check -noout
# Expected: RSA key ok

# Confirm public key is readable
openssl rsa -in public.pem -pubin -noout -text | head -5

Load keys into environment

Option 1 — Inline in .env (development only)

Encode newlines as \n and wrap in double quotes:

echo "JWT_PRIVATE_KEY=\"$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' private.pem)\"" >> .env
echo "JWT_PUBLIC_KEY=\"$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' public.pem)\"" >> .env

Option 2 — Load from file at runtime (recommended for production)

In the startup script, read the key files and export as environment variables before running the server:

export JWT_PRIVATE_KEY="$(cat /run/secrets/jwt-private.pem)"
export JWT_PUBLIC_KEY="$(cat /run/secrets/jwt-public.pem)"
npm start

With Docker secrets or a secrets manager (Vault, AWS Secrets Manager), mount the key as a file and read it this way.

Key rotation

Rotating the JWT keys invalidates all currently active tokens — every authenticated request will fail until clients re-authenticate. Plan rotation for low-traffic windows.

Rotation procedure:

  1. Generate a new RSA keypair:

    openssl genrsa -out private-new.pem 2048
    openssl rsa -in private-new.pem -pubout -out public-new.pem
    
  2. Update JWT_PRIVATE_KEY and JWT_PUBLIC_KEY in your environment or secrets store.

  3. Restart the application:

    # Graceful restart — send SIGTERM, let in-flight requests complete, then start with new keys
    kill -SIGTERM <pid>
    npm start   # or docker restart <container>
    
  4. All previously issued tokens are now invalid (wrong signature). Clients will receive 401 UNAUTHORIZED and must call POST /token again with their client_id and client_secret to get a new token.

  5. Remove the old key files:

    rm private-old.pem public-old.pem
    

Important: There is no grace period or dual-key support in Phase 1. All tokens issued with the old private key are immediately rejected after rotation. If zero-downtime key rotation is required, it is a Phase 2 feature.


CORS Configuration

Cross-Origin Resource Sharing is configured via the CORS_ORIGIN environment variable.

Value Behaviour
* (default) All origins permitted — appropriate for a public API
https://app.example.ai Only the specified origin permitted

Set in .env:

CORS_ORIGIN=https://app.example.ai

The CORS header is set by the cors middleware applied globally in src/app.ts. Credentials (cookies) are not used — all auth is Bearer token.

For production deployments where the API is only called server-to-server (agent to AgentIdP), setting CORS_ORIGIN to a specific origin or removing browser-facing CORS entirely is recommended.


Client Secret Storage

Client secrets are never stored in plaintext. The flow:

  1. On credential generation or rotation, AgentIdP generates a random secret string (sk_live_...)
  2. The plaintext is returned to the caller once only in the API response
  3. AgentIdP immediately hashes the secret with bcrypt (cost factor from bcryptjs defaults) and stores only the hash in the credentials.secret_hash column
  4. On every POST /token call, the provided client_secret is verified against the stored hash using bcrypt.compare()

Implication: If a client loses their client_secret, it cannot be recovered. They must rotate the credential to get a new one.


Secret Storage Guidance

Environment Recommendation
Local development .env file, not committed to git
CI/CD Environment variables injected by the CI platform (GitHub Actions secrets, GitLab CI variables, etc.)
Production (Docker) Docker secrets or bind-mounted files from a secrets manager
Production (cloud) AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault (Phase 2)

Never:

  • Commit .env to version control
  • Log environment variables
  • Pass secrets as command-line arguments (visible in ps aux)
  • Store keys in the database

Add .env to .gitignore:

echo ".env" >> .gitignore
echo "*.pem" >> .gitignore

Token Lifetime

JWT access tokens expire after 3600 seconds (1 hour). This is hardcoded in src/utils/jwt.ts. There is no refresh token — clients must re-authenticate via POST /token when the token expires.

The 1-hour lifetime is a balance between security (short-lived tokens limit exposure if stolen) and operational load (clients don't need to authenticate every few minutes).