You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by la...@apache.org on 2026/03/24 15:56:39 UTC

(airavata-custos) 01/01: initial signer service implementation

This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch signer-service
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit 5cb1298aed1c2dfaa26f111e0d5a41ed079b2aeb
Author: lahiruj <la...@gmail.com>
AuthorDate: Tue Mar 24 11:56:28 2026 -0400

    initial signer service implementation
---
 compose/dbinit/init-db.sh                        |   2 +
 signer/.gitignore                                |   5 +
 signer/README.md                                 | 276 ++++++++++++++
 signer/config.example.yaml                       |  66 ++++
 signer/go.mod                                    |  55 +++
 signer/go.sum                                    | 192 ++++++++++
 signer/internal/audit/logger.go                  | 145 +++++++
 signer/internal/auth/client_auth.go              |  99 +++++
 signer/internal/auth/client_auth_test.go         |  81 ++++
 signer/internal/auth/oidc_validator.go           | 296 +++++++++++++++
 signer/internal/auth/oidc_validator_test.go      | 457 +++++++++++++++++++++++
 signer/internal/cert/rsa_ecdsa_signing_test.go   | 210 +++++++++++
 signer/internal/cert/signer.go                   | 110 ++++++
 signer/internal/cert/signer_test.go              | 246 ++++++++++++
 signer/internal/cert/ssh_key.go                  | 237 ++++++++++++
 signer/internal/cert/ssh_key_test.go             | 306 +++++++++++++++
 signer/internal/config/config.go                 | 262 +++++++++++++
 signer/internal/config/config_test.go            | 122 ++++++
 signer/internal/handler/admin.go                 |  88 +++++
 signer/internal/handler/ca_public_key.go         |  93 +++++
 signer/internal/handler/ca_public_key_test.go    |  78 ++++
 signer/internal/handler/certificates.go          | 179 +++++++++
 signer/internal/handler/certificates_test.go     | 238 ++++++++++++
 signer/internal/handler/common.go                |  26 ++
 signer/internal/handler/health.go                | 119 ++++++
 signer/internal/handler/jwks.go                  | 102 +++++
 signer/internal/handler/revoke.go                | 110 ++++++
 signer/internal/handler/revoke_test.go           |  53 +++
 signer/internal/handler/sign.go                  | 298 +++++++++++++++
 signer/internal/handler/sign_test.go             |  85 +++++
 signer/internal/handler/userinfo.go              |  55 +++
 signer/internal/handler/userinfo_test.go         |  93 +++++
 signer/internal/httputil/context.go              |  95 +++++
 signer/internal/httputil/context_test.go         |  92 +++++
 signer/internal/metrics/metrics.go               |  56 +++
 signer/internal/metrics/metrics_test.go          |  62 +++
 signer/internal/policy/enforcer.go               |  97 +++++
 signer/internal/policy/enforcer_test.go          | 167 +++++++++
 signer/internal/policy/key_type.go               |  17 +
 signer/internal/server/bearer_middleware_test.go | 125 +++++++
 signer/internal/server/cors.go                   |  50 +++
 signer/internal/server/cors_test.go              | 125 +++++++
 signer/internal/server/middleware.go             | 150 ++++++++
 signer/internal/server/middleware_test.go        | 124 ++++++
 signer/internal/server/routes.go                 |  84 +++++
 signer/internal/server/server.go                 |  82 ++++
 signer/internal/store/certificate_query.go       | 148 ++++++++
 signer/internal/store/client_config.go           |  88 +++++
 signer/internal/store/db.go                      |  57 +++
 signer/internal/store/issuance_log.go            |  65 ++++
 signer/internal/store/revocation.go              |  58 +++
 signer/internal/validation/comanage.go           |  30 ++
 signer/internal/validation/noop.go               |  30 ++
 signer/internal/validation/validator.go          |  38 ++
 signer/internal/validation/validator_test.go     |  65 ++++
 signer/internal/vault/client.go                  | 390 +++++++++++++++++++
 signer/main.go                                   | 218 +++++++++++
 signer/migrations/001_initial_schema.up.sql      |  79 ++++
 58 files changed, 7376 insertions(+)

diff --git a/compose/dbinit/init-db.sh b/compose/dbinit/init-db.sh
index c253a1bd9..e94a48596 100755
--- a/compose/dbinit/init-db.sh
+++ b/compose/dbinit/init-db.sh
@@ -23,10 +23,12 @@ mariadb -u root -p"${MARIADB_ROOT_PASSWORD}" <<-EOSQL
     CREATE DATABASE IF NOT EXISTS custos;
     CREATE DATABASE IF NOT EXISTS keycloak;
     CREATE DATABASE IF NOT EXISTS access_ci;
+    CREATE DATABASE IF NOT EXISTS custos_signer;
     CREATE USER IF NOT EXISTS 'admin'@'%' IDENTIFIED BY 'admin';
     GRANT ALL PRIVILEGES ON custos.* TO 'admin'@'%';
     GRANT ALL PRIVILEGES ON keycloak.* TO 'admin'@'%';
     GRANT ALL PRIVILEGES ON access_ci.* TO 'admin'@'%';
+    GRANT ALL PRIVILEGES ON custos_signer.* TO 'admin'@'%';
     FLUSH PRIVILEGES;
 EOSQL
 
diff --git a/signer/.gitignore b/signer/.gitignore
new file mode 100644
index 000000000..3221db7ab
--- /dev/null
+++ b/signer/.gitignore
@@ -0,0 +1,5 @@
+# Local configuration (contains secrets)
+config.yaml
+
+# Build output
+custos-signer
diff --git a/signer/README.md b/signer/README.md
new file mode 100644
index 000000000..4b7761ef3
--- /dev/null
+++ b/signer/README.md
@@ -0,0 +1,276 @@
+# Custos Signer
+
+A multi-tenant SSH certificate authority for science gateways and research computing. Custos Signer issues short-lived, identity-bound OpenSSH user certificates so researchers can access HPC resources without managing long-lived SSH keys.
+
+It validates user identity through institutional OIDC providers (CILogon, Keycloak, etc.), enforces access policies (TTL limits, allowed key types, principal validation), signs certificates with per-tenant CAs stored in HashiCorp Vault, and records every transaction for audit. Certificates expire automatically -- no key distribution, no manual revocation, no cleanup.
+
+Part of [Apache Airavata Custos](https://airavata.apache.org/custos/), a security middleware platform for science gateways.
+
+## Prerequisites
+
+- Go 1.22+
+- MariaDB 10.6+
+- Vault / OpenBao with KV v2 secrets engine enabled
+
+---
+
+## Quick Start
+
+```bash
+cd signer
+go build -o custos-signer .
+cp config.example.yaml config.yaml          # edit with your settings
+./custos-signer migrate --config config.yaml # apply database schema
+./custos-signer serve --config config.yaml   # start on :8084
+```
+
+For development (auto-applies migrations on startup):
+
+```bash
+./custos-signer serve --config config.yaml --auto-migrate
+```
+
+---
+
+## Configuration
+
+Copy and edit the example configuration:
+
+```bash
+cp config.example.yaml config.yaml
+```
+
+The service loads `config.yaml` by default. Override with `--config /path/to/config.yaml`.
+
+### Environment Variable Overrides
+
+Environment variables take precedence over YAML values.
+
+| Env Var | Config Path | Description |
+|---------|------------|-------------|
+| `DB_HOST` | `database.host` | Database hostname |
+| `DB_PORT` | `database.port` | Database port |
+| `DB_NAME` | `database.name` | Database name |
+| `DB_USERNAME` | `database.username` | Database user |
+| `DB_PASSWORD` | `database.password` | Database password |
+| `VAULT_ADDRESS` | `vault.address` | Vault API address |
+| `VAULT_TOKEN` | `vault.token` | Vault authentication token |
+| `DEV_MODE` | `dev_mode.enabled` | Enable dev mode (disables OIDC validation) |
+| `DEV_DEFAULT_EMAIL` | `dev_mode.default_email` | Default email in dev mode |
+| `ALLOWED_ISSUERS` | `signer.auth.allowed_issuers` | Comma-separated list of allowed OIDC issuers |
+| `LOG_LEVEL` | `logging.level` | Log level: debug, info, warn, error |
+
+---
+
+## Database Setup
+
+### Create the database
+
+```sql
+CREATE DATABASE custos_signer CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+```
+
+If using the project's Docker Compose, the `custos_signer` database is created automatically by `compose/dbinit/init-db.sh`.
+
+### Migrations
+
+Migrations are managed separately from the server. Apply them explicitly:
+
+```bash
+./custos-signer migrate --config config.yaml
+```
+
+This applies any pending migrations and exits. It's idempotent — safe to run repeatedly. The `migrate` subcommand tracks applied versions in a `schema_migrations` table and only applies what's new.
+
+You can also apply SQL directly:
+
+```bash
+mysql -u admin -p custos_signer < migrations/001_initial_schema.up.sql
+```
+
+The initial migration creates three tables:
+- `client_ssh_configs` — client configuration and credentials
+- `certificate_issuance_logs` — audit log for certificate issuances
+- `revocation_events` — audit log for certificate revocations
+
+### Insert a client configuration
+
+```sql
+INSERT INTO client_ssh_configs
+  (tenant_id, client_id, client_secret, target_host, target_port,
+   max_ttl_seconds, allowed_key_types, enabled)
+VALUES
+  ('tenant1', 'webapp', '$2a$10$<bcrypt_hash_of_secret>', 'login.example.org', 22,
+   86400, '["ed25519","rsa","ecdsa"]', TRUE);
+```
+
+Generate the bcrypt hash with:
+
+```bash
+htpasswd -nbBC 10 "" "your-secret" | cut -d: -f2
+```
+
+---
+
+## Vault Setup
+
+### Enable KV v2 secrets engine
+
+```bash
+vault secrets enable -path=ssh-ca kv-v2
+```
+
+The service auto-generates Ed25519 CA key pairs on first signing request for each tenant-client pair. No manual key setup is required.
+
+Ensure the configured token has read/write access to `ssh-ca/data/*`.
+
+---
+
+## Running the Service
+
+### CLI Usage
+
+```
+custos-signer <command> [options]
+
+Commands:
+  serve      Start the HTTP server (default)
+  migrate    Apply pending database migrations and exit
+
+Options:
+  --config string    Path to configuration file (default "config.yaml")
+  --auto-migrate     Automatically apply migrations on startup (serve only)
+```
+
+Running without a subcommand defaults to `serve`.
+
+### Development
+
+```bash
+./custos-signer serve --config config.yaml --auto-migrate
+```
+
+### Production
+
+```bash
+./custos-signer migrate --config config.yaml   # apply pending migrations
+./custos-signer serve --config config.yaml      # start the server
+```
+
+The service listens on the port specified in configuration (default: 8084).
+
+### Docker
+
+```bash
+docker build -t custos-signer .
+
+# Apply migrations first
+docker run --rm -v $(pwd)/config.yaml:/config/config.yaml \
+  custos-signer migrate --config /config/config.yaml
+
+# Then start the server
+docker run -p 8084:8084 \
+  -v $(pwd)/config.yaml:/config/config.yaml \
+  -e DB_PASSWORD=secret \
+  -e VAULT_TOKEN=hvs.xxx \
+  custos-signer
+```
+
+The Docker image uses a `scratch` base and runs as non-root user (UID 65534).
+
+### Shutdown Behavior
+
+The service handles SIGTERM and SIGINT for graceful shutdown:
+1. Stops accepting new connections.
+2. Drains in-flight requests (up to `shutdown_timeout_seconds`, default 30s).
+3. Closes database connection pool.
+4. Exits.
+
+---
+
+## API Endpoints
+
+| Method | Path | Auth | Description |
+|--------|------|------|-------------|
+| `POST` | `/api/v1/sign` | Client credentials + OIDC token | Sign an SSH public key |
+| `POST` | `/api/v1/revoke` | Client credentials | Revoke a certificate |
+| `GET` | `/api/v1/jwks` | Client credentials | Get CA public keys in JWK format |
+| `GET` | `/api/v1/health` | None | Health check |
+| `POST` | `/api/v1/admin/rotate-ca` | Client credentials | Rotate CA keys |
+| `GET` | `/api/v1/certificates` | OIDC Bearer | List certificates for authenticated user |
+| `GET` | `/api/v1/certificates/{serial}` | OIDC Bearer | Get certificate details |
+| `GET` | `/api/v1/userinfo` | OIDC Bearer | Get authenticated user profile |
+| `GET` | `/metrics` | None | Prometheus metrics |
+
+Client credentials are passed via `X-Client-Id` (format: `{tenant_id}:{client_id}`) and `X-Client-Secret` headers. OIDC Bearer endpoints use `Authorization: Bearer <token>`.
+
+### API Examples
+
+**Health Check**
+
+```bash
+curl -s http://localhost:8084/api/v1/health | jq .
+```
+
+**Sign a Certificate**
+
+```bash
+ssh-keygen -t ed25519 -f /tmp/testkey -N ""
+
+curl -s -X POST http://localhost:8084/api/v1/sign \
+  -H "Content-Type: application/json" \
+  -H "X-Client-Id: tenant1:webapp" \
+  -H "X-Client-Secret: your-secret" \
+  -d '{
+    "principal": "jdoe",
+    "ttl_seconds": 3600,
+    "public_key": "'"$(cat /tmp/testkey.pub)"'",
+    "user_access_token": "<oidc-access-token>"
+  }' | jq .
+```
+
+**Revoke a Certificate**
+
+```bash
+curl -s -X POST http://localhost:8084/api/v1/revoke \
+  -H "Content-Type: application/json" \
+  -H "X-Client-Id: tenant1:webapp" \
+  -H "X-Client-Secret: your-secret" \
+  -d '{"serial_number": 42, "reason": "User deprovisioned"}' | jq .
+```
+
+**Get CA Public Keys (JWK format)**
+
+```bash
+curl -s http://localhost:8084/api/v1/jwks \
+  -H "X-Client-Id: tenant1:webapp" \
+  -H "X-Client-Secret: your-secret" | jq .
+```
+
+**Rotate CA Keys**
+
+```bash
+curl -s -X POST http://localhost:8084/api/v1/admin/rotate-ca \
+  -H "X-Client-Id: tenant1:webapp" \
+  -H "X-Client-Secret: your-secret" | jq .
+```
+
+---
+
+## Troubleshooting
+
+| Error | Cause | Fix |
+|-------|-------|-----|
+| "failed to connect to database" | MariaDB unreachable or wrong credentials | Check host/port, verify DB user permissions, ensure database exists |
+| "failed to create vault client" | Vault unreachable or bad token | Check `VAULT_ADDRESS`, ensure token is valid, verify KV v2 enabled |
+| HTTP 401 on authenticated requests | Bad client credentials | Verify `X-Client-Id` is `tenant_id:client_id` format, check bcrypt hash matches |
+| HTTP 403 "Client is disabled" | Client row has `enabled=false` | Set `enabled=true` in `client_ssh_configs` |
+| HTTP 401 "Issuer not allowed" | OIDC issuer not in allowed list | Add issuer to `allowed_issuers`, or set to empty list to accept all |
+| HTTP 503 "CA key storage unavailable" | Vault down during signing | Check Vault status and network connectivity |
+| Token validation errors in dev | No OIDC provider configured | Set `DEV_MODE=true` for development (returns default identity) |
+
+---
+
+## License
+
+Apache License 2.0
diff --git a/signer/config.example.yaml b/signer/config.example.yaml
new file mode 100644
index 000000000..1552f099c
--- /dev/null
+++ b/signer/config.example.yaml
@@ -0,0 +1,66 @@
+server:
+  port: 8084                           # HTTP listen port
+  read_timeout_seconds: 30             # HTTP read timeout
+  write_timeout_seconds: 30            # HTTP write timeout
+  shutdown_timeout_seconds: 30         # Graceful shutdown drain
+
+database:
+  host: localhost                      # env: DB_HOST
+  port: 3306                           # env: DB_PORT
+  name: custos_signer                  # env: DB_NAME
+  username: admin                      # env: DB_USERNAME
+  password: admin                      # env: DB_PASSWORD
+  max_open_conns: 25
+  max_idle_conns: 5
+  conn_max_lifetime_seconds: 300
+
+vault:
+  address: http://localhost:8200       # env: VAULT_ADDRESS
+  token: root-token                    # env: VAULT_TOKEN
+  mount_path: ssh-ca                   # KV v2 mount path
+  timeout_seconds: 10
+
+signer:
+  ca:
+    rotation:
+      period_hours: 2160              # 90 days
+      overlap_hours: 2
+  policy:
+    defaults:
+      max_ttl_seconds: 86400          # 24 hours
+      allowed_key_types:
+        - ed25519
+        - rsa
+        - ecdsa
+  auth:
+    allowed_issuers:                   # env: ALLOWED_ISSUERS (comma-separated)
+      - https://cilogon.org
+    oidc:
+      jwks_cache_ttl_seconds: 300     # 5 minutes
+      jwks_max_providers: 10
+      timeout_seconds: 10
+  validation:
+    principal_validator: noop          # "noop" or "comanage"
+    comanage:
+      registry_url: ""
+      api_path: /registry/co_people.json
+      timeout_seconds: 10
+      verify_ssl: true
+
+dev_mode:
+  enabled: false                       # env: DEV_MODE (disables OIDC validation)
+  default_email: dev@localhost           # env: DEV_DEFAULT_EMAIL
+
+cors:
+  enabled: false                         # Enable CORS for cross-origin requests
+  allowed_origins:                       # Origins allowed for cross-origin requests
+    - http://localhost:5173
+
+logging:
+  level: info                          # debug, info, warn, error
+  format: json                         # json or text
+  audit_level: info
+
+metrics:
+  enabled: true
+  path: /metrics
diff --git a/signer/go.mod b/signer/go.mod
new file mode 100644
index 000000000..4165ac052
--- /dev/null
+++ b/signer/go.mod
@@ -0,0 +1,55 @@
+module github.com/apache/airavata-custos/signer
+
+go 1.25.0
+
+require (
+	github.com/go-chi/chi/v5 v5.1.0
+	github.com/go-sql-driver/mysql v1.8.1
+	github.com/golang-migrate/migrate/v4 v4.19.1
+	github.com/hashicorp/vault/api v1.15.0
+	github.com/lestrrat-go/jwx/v2 v2.1.3
+	github.com/prometheus/client_golang v1.20.5
+	golang.org/x/crypto v0.45.0
+	gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+	filippo.io/edwards25519 v1.1.0 // indirect
+	github.com/beorn7/perks v1.0.1 // indirect
+	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+	github.com/cespare/xxhash/v2 v2.3.0 // indirect
+	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
+	github.com/go-jose/go-jose/v4 v4.0.5 // indirect
+	github.com/goccy/go-json v0.10.3 // indirect
+	github.com/hashicorp/errwrap v1.1.0 // indirect
+	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+	github.com/hashicorp/go-multierror v1.1.1 // indirect
+	github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
+	github.com/hashicorp/go-rootcerts v1.0.2 // indirect
+	github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect
+	github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
+	github.com/hashicorp/go-sockaddr v1.0.2 // indirect
+	github.com/hashicorp/hcl v1.0.0 // indirect
+	github.com/klauspost/compress v1.17.9 // indirect
+	github.com/kr/text v0.2.0 // indirect
+	github.com/lestrrat-go/blackmagic v1.0.2 // indirect
+	github.com/lestrrat-go/httpcc v1.0.1 // indirect
+	github.com/lestrrat-go/httprc v1.0.6 // indirect
+	github.com/lestrrat-go/iter v1.0.2 // indirect
+	github.com/lestrrat-go/option v1.0.1 // indirect
+	github.com/mitchellh/go-homedir v1.1.0 // indirect
+	github.com/mitchellh/mapstructure v1.5.0 // indirect
+	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+	github.com/prometheus/client_model v0.6.1 // indirect
+	github.com/prometheus/common v0.55.0 // indirect
+	github.com/prometheus/procfs v0.15.1 // indirect
+	github.com/rogpeppe/go-internal v1.12.0 // indirect
+	github.com/ryanuber/go-glob v1.0.0 // indirect
+	github.com/segmentio/asm v1.2.0 // indirect
+	golang.org/x/net v0.47.0 // indirect
+	golang.org/x/sync v0.20.0 // indirect
+	golang.org/x/sys v0.38.0 // indirect
+	golang.org/x/text v0.31.0 // indirect
+	golang.org/x/time v0.12.0 // indirect
+	google.golang.org/protobuf v1.36.7 // indirect
+)
diff --git a/signer/go.sum b/signer/go.sum
new file mode 100644
index 000000000..d12cd5b9b
--- /dev/null
+++ b/signer/go.sum
@@ -0,0 +1,192 @@
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
+github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
+github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
+github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
+github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
+github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
+github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
+github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
+github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
+github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
+github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
+github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
+github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
+github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
+github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
+github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
+github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
+github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
+github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
+github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ=
+github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8=
+github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U=
+github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
+github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
+github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc=
+github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA=
+github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8=
+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
+github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
+github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
+github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
+github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
+github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
+github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
+github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
+github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo=
+github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo=
+github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
+github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
+github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
+github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
+github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
+github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
+github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
+github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
+github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
+github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
+github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
+github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
+github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
+github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
+github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
+go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
+go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
+go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
+go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
+go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
+go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
+golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
+golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
+google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/signer/internal/audit/logger.go b/signer/internal/audit/logger.go
new file mode 100644
index 000000000..b73529f3d
--- /dev/null
+++ b/signer/internal/audit/logger.go
@@ -0,0 +1,145 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package audit provides dual audit logging (database + structured JSON log).
+package audit
+
+import (
+	"context"
+	"log/slog"
+	"time"
+
+	"github.com/apache/airavata-custos/signer/internal/store"
+)
+
+// Logger writes audit entries to both the database and structured slog output.
+type Logger struct {
+	db      *store.DB
+	slogger *slog.Logger
+}
+
+func NewLogger(db *store.DB, baseLogger *slog.Logger) *Logger {
+	return &Logger{
+		db:      db,
+		slogger: baseLogger.With("logger", "audit"),
+	}
+}
+
+type IssuanceEntry struct {
+	TenantID             string
+	ClientID             string
+	SerialNumber         int64
+	KeyID                string
+	Principal            string
+	UserEmail            string
+	PublicKeyFingerprint string
+	CAFingerprint        string
+	ValidAfter           time.Time
+	ValidBefore          time.Time
+	SourceIP             string
+	UserAccessTokenHash  string
+	CorrelationID        string
+}
+
+func (l *Logger) LogIssuance(ctx context.Context, entry *IssuanceEntry) error {
+	dbLog := &store.IssuanceLog{
+		TenantID:             entry.TenantID,
+		ClientID:             entry.ClientID,
+		SerialNumber:         entry.SerialNumber,
+		KeyID:                entry.KeyID,
+		Principal:            entry.Principal,
+		UserEmail:            entry.UserEmail,
+		PublicKeyFingerprint: entry.PublicKeyFingerprint,
+		CAFingerprint:        entry.CAFingerprint,
+		ValidAfter:           entry.ValidAfter,
+		ValidBefore:          entry.ValidBefore,
+		SourceIP:             entry.SourceIP,
+		UserAccessTokenHash:  entry.UserAccessTokenHash,
+	}
+
+	if err := l.db.InsertIssuanceLog(ctx, dbLog); err != nil {
+		return err
+	}
+
+	l.slogger.InfoContext(ctx, "certificate issued",
+		"event_type", "certificate_issuance",
+		"timestamp", time.Now().UTC().Format(time.RFC3339),
+		"tenant_id", entry.TenantID,
+		"client_id", entry.ClientID,
+		"serial_number", entry.SerialNumber,
+		"key_id", entry.KeyID,
+		"principal", entry.Principal,
+		"user_email", entry.UserEmail,
+		"public_key_fingerprint", entry.PublicKeyFingerprint,
+		"ca_fingerprint", entry.CAFingerprint,
+		"valid_after", entry.ValidAfter.Unix(),
+		"valid_before", entry.ValidBefore.Unix(),
+		"source_ip", entry.SourceIP,
+		"user_token_hash", entry.UserAccessTokenHash,
+		"correlation_id", entry.CorrelationID,
+	)
+
+	return nil
+}
+
+type RevocationEntry struct {
+	TenantID      string
+	ClientID      string
+	SerialNumber  *int64
+	KeyID         *string
+	CAFingerprint *string
+	Reason        string
+	RevokedBy     string
+	CorrelationID string
+}
+
+func (l *Logger) LogRevocation(ctx context.Context, entry *RevocationEntry) error {
+	ev := &store.RevocationEvent{
+		TenantID:      entry.TenantID,
+		ClientID:      entry.ClientID,
+		SerialNumber:  entry.SerialNumber,
+		KeyID:         entry.KeyID,
+		CAFingerprint: entry.CAFingerprint,
+		Reason:        entry.Reason,
+		RevokedBy:     entry.RevokedBy,
+	}
+
+	if err := l.db.InsertRevocationEvent(ctx, ev); err != nil {
+		return err
+	}
+
+	attrs := []any{
+		"event_type", "certificate_revocation",
+		"timestamp", time.Now().UTC().Format(time.RFC3339),
+		"tenant_id", entry.TenantID,
+		"client_id", entry.ClientID,
+		"reason", entry.Reason,
+		"revoked_by", entry.RevokedBy,
+		"correlation_id", entry.CorrelationID,
+	}
+	if entry.SerialNumber != nil {
+		attrs = append(attrs, "serial_number", *entry.SerialNumber)
+	}
+	if entry.KeyID != nil {
+		attrs = append(attrs, "key_id", *entry.KeyID)
+	}
+	if entry.CAFingerprint != nil {
+		attrs = append(attrs, "ca_fingerprint", *entry.CAFingerprint)
+	}
+
+	l.slogger.InfoContext(ctx, "certificate revoked", attrs...)
+
+	return nil
+}
diff --git a/signer/internal/auth/client_auth.go b/signer/internal/auth/client_auth.go
new file mode 100644
index 000000000..550306d9d
--- /dev/null
+++ b/signer/internal/auth/client_auth.go
@@ -0,0 +1,99 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package auth handles client credential authentication and OIDC token validation.
+package auth
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"golang.org/x/crypto/bcrypt"
+
+	"github.com/apache/airavata-custos/signer/internal/store"
+)
+
+type ClientAuthenticator struct {
+	db *store.DB
+}
+
+func NewClientAuthenticator(db *store.DB) *ClientAuthenticator {
+	return &ClientAuthenticator{db: db}
+}
+
+// ParseClientID parses the X-Client-Id header value into tenant_id and client_id.
+// The header format is {tenant_id}:{client_id}, split on the first colon only.
+func ParseClientID(header string) (tenantID, clientID string, err error) {
+	if header == "" {
+		return "", "", fmt.Errorf("empty client ID header")
+	}
+	idx := strings.Index(header, ":")
+	if idx < 0 {
+		return "", "", fmt.Errorf("invalid client ID format: no colon separator")
+	}
+	tenantID = header[:idx]
+	clientID = header[idx+1:]
+	if tenantID == "" || clientID == "" {
+		return "", "", fmt.Errorf("invalid client ID format: empty tenant or client")
+	}
+	return tenantID, clientID, nil
+}
+
+type AuthResult struct {
+	Config *store.ClientConfig
+}
+
+func (a *ClientAuthenticator) Authenticate(ctx context.Context, clientIDHeader, secretHeader string) (*AuthResult, error) {
+	if clientIDHeader == "" {
+		return nil, &AuthError{Code: "unauthorized", Message: "Missing X-Client-Id header", Status: 401}
+	}
+	if secretHeader == "" {
+		return nil, &AuthError{Code: "unauthorized", Message: "Missing X-Client-Secret header", Status: 401}
+	}
+
+	tenantID, clientID, err := ParseClientID(clientIDHeader)
+	if err != nil {
+		return nil, &AuthError{Code: "unauthorized", Message: err.Error(), Status: 401}
+	}
+
+	cfg, err := a.db.GetClientConfig(ctx, tenantID, clientID)
+	if err != nil {
+		return nil, fmt.Errorf("looking up client config: %w", err)
+	}
+	if cfg == nil {
+		return nil, &AuthError{Code: "unauthorized", Message: "Unknown client", Status: 401}
+	}
+
+	if err := bcrypt.CompareHashAndPassword([]byte(cfg.ClientSecret), []byte(secretHeader)); err != nil {
+		return nil, &AuthError{Code: "unauthorized", Message: "Invalid client secret", Status: 401}
+	}
+
+	if !cfg.Enabled {
+		return nil, &AuthError{Code: "client_disabled", Message: "Client is disabled", Status: 403}
+	}
+
+	return &AuthResult{Config: cfg}, nil
+}
+
+type AuthError struct {
+	Code    string
+	Message string
+	Status  int
+}
+
+func (e *AuthError) Error() string {
+	return e.Message
+}
diff --git a/signer/internal/auth/client_auth_test.go b/signer/internal/auth/client_auth_test.go
new file mode 100644
index 000000000..2bd4bf93b
--- /dev/null
+++ b/signer/internal/auth/client_auth_test.go
@@ -0,0 +1,81 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package auth
+
+import (
+	"testing"
+)
+
+func TestParseClientID_NoColon(t *testing.T) {
+	_, _, err := ParseClientID("nocolonhere")
+	if err == nil {
+		t.Error("expected error for client ID without colon")
+	}
+}
+
+func TestParseClientID_MultipleColons(t *testing.T) {
+	tenant, client, err := ParseClientID("t1:c1:extra")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if tenant != "t1" {
+		t.Errorf("expected tenant t1, got %s", tenant)
+	}
+	if client != "c1:extra" {
+		t.Errorf("expected client c1:extra, got %s", client)
+	}
+}
+
+func TestParseClientID_Empty(t *testing.T) {
+	_, _, err := ParseClientID("")
+	if err == nil {
+		t.Error("expected error for empty client ID")
+	}
+}
+
+func TestParseClientID_Valid(t *testing.T) {
+	tenant, client, err := ParseClientID("tenant1:webapp")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if tenant != "tenant1" {
+		t.Errorf("expected tenant1, got %s", tenant)
+	}
+	if client != "webapp" {
+		t.Errorf("expected webapp, got %s", client)
+	}
+}
+
+func TestParseClientID_EmptyTenant(t *testing.T) {
+	_, _, err := ParseClientID(":client")
+	if err == nil {
+		t.Error("expected error for empty tenant")
+	}
+}
+
+func TestParseClientID_EmptyClient(t *testing.T) {
+	_, _, err := ParseClientID("tenant:")
+	if err == nil {
+		t.Error("expected error for empty client")
+	}
+}
+
+func TestAuthError_Interface(t *testing.T) {
+	err := &AuthError{Code: "unauthorized", Message: "test", Status: 401}
+	if err.Error() != "test" {
+		t.Errorf("expected 'test', got %s", err.Error())
+	}
+}
diff --git a/signer/internal/auth/oidc_validator.go b/signer/internal/auth/oidc_validator.go
new file mode 100644
index 000000000..b5b8632d3
--- /dev/null
+++ b/signer/internal/auth/oidc_validator.go
@@ -0,0 +1,296 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package auth
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/lestrrat-go/jwx/v2/jwk"
+	"github.com/lestrrat-go/jwx/v2/jwt"
+	"golang.org/x/sync/singleflight"
+
+	"github.com/apache/airavata-custos/signer/internal/config"
+)
+
+type UserIdentity struct {
+	Issuer    string
+	Subject   string
+	Email     string
+	Principal string // extracted label for logging
+}
+
+type OIDCValidator struct {
+	enabled        bool
+	defaultEmail   string
+	allowedIssuers []string
+	cacheTTL       time.Duration
+	maxProviders   int
+	httpTimeout    time.Duration
+	httpClient     *http.Client
+
+	mu      sync.RWMutex
+	cache   map[string]*jwksEntry
+	sfGroup singleflight.Group
+	// JWKSFetcher can be overridden for testing
+	JWKSFetcher func(ctx context.Context, issuer string) (jwk.Set, error)
+}
+
+type jwksEntry struct {
+	set       jwk.Set
+	fetchedAt time.Time
+}
+
+func NewOIDCValidator(cfg config.AuthConfig, devMode config.DevModeConfig) *OIDCValidator {
+	enabled := !devMode.Enabled
+	defaultEmail := devMode.DefaultEmail
+	if defaultEmail == "" {
+		defaultEmail = "dev@localhost"
+	}
+
+	v := &OIDCValidator{
+		enabled:        enabled,
+		defaultEmail:   defaultEmail,
+		allowedIssuers: cfg.AllowedIssuers,
+		cacheTTL:       time.Duration(cfg.OIDC.JWKSCacheTTLSeconds) * time.Second,
+		maxProviders:   cfg.OIDC.JWKSMaxProviders,
+		httpTimeout:    time.Duration(cfg.OIDC.TimeoutSeconds) * time.Second,
+		cache:          make(map[string]*jwksEntry),
+	}
+	if v.cacheTTL == 0 {
+		v.cacheTTL = 5 * time.Minute
+	}
+	if v.maxProviders == 0 {
+		v.maxProviders = 10
+	}
+	if v.httpTimeout == 0 {
+		v.httpTimeout = 10 * time.Second
+	}
+	v.httpClient = &http.Client{Timeout: v.httpTimeout}
+	return v
+}
+
+// ValidateAccessToken returns a default identity when dev mode is active;
+// otherwise performs full OIDC token validation via JWKS.
+func (v *OIDCValidator) ValidateAccessToken(ctx context.Context, tokenString string) (*UserIdentity, error) {
+	if !v.enabled {
+		return &UserIdentity{
+			Issuer:    "dev-mode",
+			Subject:   "dev-user",
+			Email:     v.defaultEmail,
+			Principal: "dev-user",
+		}, nil
+	}
+
+	if tokenString == "" {
+		return nil, &AuthError{Code: "invalid_token", Message: "Missing access token", Status: 401}
+	}
+
+	// Parse without verification first to get issuer
+	insecureToken, err := jwt.Parse([]byte(tokenString), jwt.WithVerify(false), jwt.WithValidate(false))
+	if err != nil {
+		return nil, &AuthError{Code: "invalid_token", Message: fmt.Sprintf("Failed to parse token: %v", err), Status: 401}
+	}
+
+	issuer := insecureToken.Issuer()
+	if issuer == "" {
+		return nil, &AuthError{Code: "invalid_token", Message: "Token missing issuer claim", Status: 401}
+	}
+
+	if len(v.allowedIssuers) > 0 {
+		allowed := false
+		for _, ai := range v.allowedIssuers {
+			if ai == issuer {
+				allowed = true
+				break
+			}
+		}
+		if !allowed {
+			return nil, &AuthError{Code: "invalid_token", Message: "Issuer not allowed", Status: 401}
+		}
+	}
+
+	keySet, err := v.getJWKS(ctx, issuer)
+	if err != nil {
+		return nil, &AuthError{Code: "invalid_token", Message: fmt.Sprintf("Failed to fetch JWKS: %v", err), Status: 401}
+	}
+
+	// Parse, verify signature, and validate claims in a single pass
+	token, err := jwt.Parse([]byte(tokenString),
+		jwt.WithKeySet(keySet),
+		jwt.WithValidate(true),
+	)
+	if err != nil {
+		errMsg := err.Error()
+		if containsAny(errMsg, "exp", "expired") {
+			return nil, &AuthError{Code: "invalid_token", Message: "Token has expired", Status: 401}
+		}
+		if containsAny(errMsg, "nbf", "not yet valid") {
+			return nil, &AuthError{Code: "invalid_token", Message: "Token is not yet valid", Status: 401}
+		}
+		return nil, &AuthError{Code: "invalid_token", Message: fmt.Sprintf("Token validation failed: %v", err), Status: 401}
+	}
+
+	principal := extractPrincipal(token)
+	if principal == "" {
+		return nil, &AuthError{Code: "invalid_token", Message: "No valid principal found in token claims", Status: 401}
+	}
+
+	var email string
+	if v, ok := token.Get("email"); ok {
+		if s, ok := v.(string); ok {
+			email = s
+		}
+	}
+
+	return &UserIdentity{
+		Issuer:    issuer,
+		Subject:   token.Subject(),
+		Email:     email,
+		Principal: principal,
+	}, nil
+}
+
+func extractPrincipal(token jwt.Token) string {
+	// Priority: sub, preferred_username, email, username
+	if s := token.Subject(); s != "" {
+		return s
+	}
+	if v, ok := token.Get("preferred_username"); ok {
+		if s, ok := v.(string); ok && s != "" {
+			return s
+		}
+	}
+	if v, ok := token.Get("email"); ok {
+		if s, ok := v.(string); ok && s != "" {
+			return s
+		}
+	}
+	if v, ok := token.Get("username"); ok {
+		if s, ok := v.(string); ok && s != "" {
+			return s
+		}
+	}
+	return ""
+}
+
+func (v *OIDCValidator) getJWKS(ctx context.Context, issuer string) (jwk.Set, error) {
+	v.mu.RLock()
+	entry, ok := v.cache[issuer]
+	v.mu.RUnlock()
+
+	if ok && time.Since(entry.fetchedAt) < v.cacheTTL {
+		return entry.set, nil
+	}
+
+	// Use singleflight to coalesce concurrent fetches for the same issuer
+	result, err, _ := v.sfGroup.Do(issuer, func() (interface{}, error) {
+		// Double-check cache after acquiring singleflight slot
+		v.mu.RLock()
+		entry2, ok2 := v.cache[issuer]
+		v.mu.RUnlock()
+		if ok2 && time.Since(entry2.fetchedAt) < v.cacheTTL {
+			return entry2.set, nil
+		}
+
+		var keySet jwk.Set
+		var fetchErr error
+		if v.JWKSFetcher != nil {
+			keySet, fetchErr = v.JWKSFetcher(ctx, issuer)
+		} else {
+			keySet, fetchErr = v.fetchJWKSFromIssuer(ctx, issuer)
+		}
+		if fetchErr != nil {
+			return nil, fetchErr
+		}
+
+		v.mu.Lock()
+		defer v.mu.Unlock()
+
+		// Evict oldest if at capacity
+		if len(v.cache) >= v.maxProviders {
+			var oldestKey string
+			var oldestTime time.Time
+			for k, e := range v.cache {
+				if oldestKey == "" || e.fetchedAt.Before(oldestTime) {
+					oldestKey = k
+					oldestTime = e.fetchedAt
+				}
+			}
+			delete(v.cache, oldestKey)
+		}
+
+		v.cache[issuer] = &jwksEntry{set: keySet, fetchedAt: time.Now()}
+		return keySet, nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return result.(jwk.Set), nil
+}
+
+func (v *OIDCValidator) fetchJWKSFromIssuer(ctx context.Context, issuer string) (jwk.Set, error) {
+	ctx2, cancel := context.WithTimeout(ctx, v.httpTimeout)
+	defer cancel()
+
+	// Try standard OIDC discovery to get jwks_uri
+	discoveryURL := issuer + "/.well-known/openid-configuration"
+	req, err := http.NewRequestWithContext(ctx2, http.MethodGet, discoveryURL, nil)
+	if err == nil {
+		resp, err := v.httpClient.Do(req)
+		if err == nil && resp.StatusCode == 200 {
+			var discovery struct {
+				JWKSURI string `json:"jwks_uri"`
+			}
+			if err := json.NewDecoder(resp.Body).Decode(&discovery); err == nil && discovery.JWKSURI != "" {
+				resp.Body.Close()
+				keySet, err := jwk.Fetch(ctx2, discovery.JWKSURI)
+				if err == nil {
+					return keySet, nil
+				}
+			} else {
+				resp.Body.Close()
+			}
+		} else if resp != nil {
+			resp.Body.Close()
+		}
+	}
+
+	// Fallback: try standard JWKS URI
+	keySet, err := jwk.Fetch(ctx2, issuer+"/.well-known/jwks.json")
+	if err != nil {
+		// Try certs endpoint (Google-style)
+		keySet, err = jwk.Fetch(ctx2, issuer+"/certs")
+		if err != nil {
+			return nil, fmt.Errorf("fetching JWKS from %s: %w", issuer, err)
+		}
+	}
+	return keySet, nil
+}
+
+func containsAny(s string, substrs ...string) bool {
+	for _, sub := range substrs {
+		if strings.Contains(s, sub) {
+			return true
+		}
+	}
+	return false
+}
diff --git a/signer/internal/auth/oidc_validator_test.go b/signer/internal/auth/oidc_validator_test.go
new file mode 100644
index 000000000..c399a51fe
--- /dev/null
+++ b/signer/internal/auth/oidc_validator_test.go
@@ -0,0 +1,457 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package auth
+
+import (
+	"context"
+	"crypto"
+	"crypto/ecdsa"
+	"crypto/ed25519"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/rsa"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/lestrrat-go/jwx/v2/jwa"
+	"github.com/lestrrat-go/jwx/v2/jwk"
+	"github.com/lestrrat-go/jwx/v2/jwt"
+
+	"github.com/apache/airavata-custos/signer/internal/config"
+)
+
+func createTestJWT(t *testing.T, key crypto.Signer, alg jwa.SignatureAlgorithm, claims map[string]interface{}) string {
+	t.Helper()
+
+	tok := jwt.New()
+	for k, v := range claims {
+		tok.Set(k, v)
+	}
+
+	jwkKey, err := jwk.FromRaw(key)
+	if err != nil {
+		t.Fatalf("creating JWK from key: %v", err)
+	}
+	jwkKey.Set(jwk.KeyIDKey, "test-key-1")
+	jwkKey.Set(jwk.AlgorithmKey, alg)
+
+	signed, err := jwt.Sign(tok, jwt.WithKey(alg, jwkKey))
+	if err != nil {
+		t.Fatalf("signing JWT: %v", err)
+	}
+	return string(signed)
+}
+
+func createJWKSServer(t *testing.T, pubKey crypto.PublicKey, alg jwa.SignatureAlgorithm, requestCount *atomic.Int32) *httptest.Server {
+	t.Helper()
+
+	jwkKey, err := jwk.FromRaw(pubKey)
+	if err != nil {
+		t.Fatalf("creating JWK: %v", err)
+	}
+	jwkKey.Set(jwk.KeyIDKey, "test-key-1")
+	jwkKey.Set(jwk.AlgorithmKey, alg)
+
+	set := jwk.NewSet()
+	set.AddKey(jwkKey)
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) {
+		if requestCount != nil {
+			requestCount.Add(1)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(set)
+	})
+
+	return httptest.NewServer(mux)
+}
+
+func newTestValidator(allowedIssuers []string) *OIDCValidator {
+	return NewOIDCValidator(config.AuthConfig{
+		AllowedIssuers: allowedIssuers,
+		OIDC: config.OIDCConfig{
+			JWKSCacheTTLSeconds: 300,
+			JWKSMaxProviders:    10,
+			TimeoutSeconds:      10,
+		},
+	}, config.DevModeConfig{})
+}
+
+func TestOIDCValidator_DevMode(t *testing.T) {
+	v := NewOIDCValidator(config.AuthConfig{}, config.DevModeConfig{
+		Enabled:      true,
+		DefaultEmail: "test@dev.local",
+	})
+
+	identity, err := v.ValidateAccessToken(context.Background(), "garbage-token")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if identity.Issuer != "dev-mode" {
+		t.Errorf("expected issuer 'dev-mode', got %s", identity.Issuer)
+	}
+	if identity.Subject != "dev-user" {
+		t.Errorf("expected subject 'dev-user', got %s", identity.Subject)
+	}
+	if identity.Email != "test@dev.local" {
+		t.Errorf("expected email 'test@dev.local', got %s", identity.Email)
+	}
+	if identity.Principal != "dev-user" {
+		t.Errorf("expected principal 'dev-user', got %s", identity.Principal)
+	}
+}
+
+func TestOIDCValidator_DevModeDefaultEmail(t *testing.T) {
+	v := NewOIDCValidator(config.AuthConfig{}, config.DevModeConfig{
+		Enabled: true,
+	})
+
+	identity, err := v.ValidateAccessToken(context.Background(), "any-token")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if identity.Email != "dev@localhost" {
+		t.Errorf("expected email 'dev@localhost', got %s", identity.Email)
+	}
+}
+
+func TestOIDCValidator_ValidRS256Token(t *testing.T) {
+	rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	var reqCount atomic.Int32
+	server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, &reqCount)
+	defer server.Close()
+
+	v := newTestValidator(nil)
+	v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+		return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+	}
+
+	token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+		"iss": server.URL,
+		"sub": "user123",
+		"exp": time.Now().Add(time.Hour).Unix(),
+	})
+
+	identity, err := v.ValidateAccessToken(context.Background(), token)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if identity.Subject != "user123" {
+		t.Errorf("expected subject user123, got %s", identity.Subject)
+	}
+}
+
+func TestOIDCValidator_ValidES256Token(t *testing.T) {
+	ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	server := createJWKSServer(t, &ecKey.PublicKey, jwa.ES256, nil)
+	defer server.Close()
+
+	v := newTestValidator(nil)
+	v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+		return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+	}
+
+	token := createTestJWT(t, ecKey, jwa.ES256, map[string]interface{}{
+		"iss": server.URL,
+		"sub": "ecuser",
+		"exp": time.Now().Add(time.Hour).Unix(),
+	})
+
+	identity, err := v.ValidateAccessToken(context.Background(), token)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if identity.Subject != "ecuser" {
+		t.Errorf("expected subject ecuser, got %s", identity.Subject)
+	}
+}
+
+func TestOIDCValidator_ValidEdDSAToken(t *testing.T) {
+	_, edKey, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	server := createJWKSServer(t, edKey.Public(), jwa.EdDSA, nil)
+	defer server.Close()
+
+	v := newTestValidator(nil)
+	v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+		return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+	}
+
+	token := createTestJWT(t, edKey, jwa.EdDSA, map[string]interface{}{
+		"iss": server.URL,
+		"sub": "eduser",
+		"exp": time.Now().Add(time.Hour).Unix(),
+	})
+
+	identity, err := v.ValidateAccessToken(context.Background(), token)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if identity.Subject != "eduser" {
+		t.Errorf("expected subject eduser, got %s", identity.Subject)
+	}
+}
+
+func TestOIDCValidator_ExpiredToken(t *testing.T) {
+	rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+	server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, nil)
+	defer server.Close()
+
+	v := newTestValidator(nil)
+	v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+		return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+	}
+
+	token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+		"iss": server.URL,
+		"sub": "user",
+		"exp": time.Now().Add(-time.Hour).Unix(),
+	})
+
+	_, err := v.ValidateAccessToken(context.Background(), token)
+	if err == nil {
+		t.Fatal("expected error for expired token")
+	}
+	authErr, ok := err.(*AuthError)
+	if !ok {
+		t.Fatalf("expected AuthError, got %T", err)
+	}
+	if authErr.Code != "invalid_token" {
+		t.Errorf("expected code invalid_token, got %s", authErr.Code)
+	}
+}
+
+func TestOIDCValidator_FutureNBF(t *testing.T) {
+	rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+	server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, nil)
+	defer server.Close()
+
+	v := newTestValidator(nil)
+	v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+		return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+	}
+
+	token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+		"iss": server.URL,
+		"sub": "user",
+		"exp": time.Now().Add(2 * time.Hour).Unix(),
+		"nbf": time.Now().Add(time.Hour).Unix(),
+	})
+
+	_, err := v.ValidateAccessToken(context.Background(), token)
+	if err == nil {
+		t.Fatal("expected error for future nbf token")
+	}
+}
+
+func TestOIDCValidator_InvalidSignature(t *testing.T) {
+	rsaKey1, _ := rsa.GenerateKey(rand.Reader, 2048)
+	rsaKey2, _ := rsa.GenerateKey(rand.Reader, 2048)
+
+	// JWKS serves key2's public key, but token signed with key1
+	server := createJWKSServer(t, &rsaKey2.PublicKey, jwa.RS256, nil)
+	defer server.Close()
+
+	v := newTestValidator(nil)
+	v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+		return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+	}
+
+	token := createTestJWT(t, rsaKey1, jwa.RS256, map[string]interface{}{
+		"iss": server.URL,
+		"sub": "user",
+		"exp": time.Now().Add(time.Hour).Unix(),
+	})
+
+	_, err := v.ValidateAccessToken(context.Background(), token)
+	if err == nil {
+		t.Fatal("expected error for invalid signature")
+	}
+}
+
+func TestOIDCValidator_PrincipalFromSub(t *testing.T) {
+	rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+	server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, nil)
+	defer server.Close()
+
+	v := newTestValidator(nil)
+	v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+		return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+	}
+
+	token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+		"iss":   server.URL,
+		"sub":   "user123",
+		"email": "user@example.com",
+		"exp":   time.Now().Add(time.Hour).Unix(),
+	})
+
+	identity, err := v.ValidateAccessToken(context.Background(), token)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if identity.Principal != "user123" {
+		t.Errorf("expected principal user123, got %s", identity.Principal)
+	}
+}
+
+func TestOIDCValidator_EmptySubject(t *testing.T) {
+	rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+	server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, nil)
+	defer server.Close()
+
+	v := newTestValidator(nil)
+	v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+		return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+	}
+
+	token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+		"iss": server.URL,
+		"sub": "",
+		"exp": time.Now().Add(time.Hour).Unix(),
+	})
+
+	_, err := v.ValidateAccessToken(context.Background(), token)
+	if err == nil {
+		t.Fatal("expected error for empty subject")
+	}
+	authErr, ok := err.(*AuthError)
+	if !ok {
+		t.Fatalf("expected AuthError, got %T", err)
+	}
+	if authErr.Message != "No valid principal found in token claims" {
+		t.Errorf("expected 'No valid principal found' message, got %s", authErr.Message)
+	}
+}
+
+func TestOIDCValidator_MissingIssuer(t *testing.T) {
+	rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+
+	v := newTestValidator(nil)
+
+	token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+		"sub": "user",
+		"exp": time.Now().Add(time.Hour).Unix(),
+	})
+
+	_, err := v.ValidateAccessToken(context.Background(), token)
+	if err == nil {
+		t.Fatal("expected error for missing issuer")
+	}
+}
+
+func TestOIDCValidator_JWKSCaching(t *testing.T) {
+	rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+
+	var fetchCount atomic.Int32
+	server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, nil)
+	defer server.Close()
+
+	v := newTestValidator(nil)
+	v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+		fetchCount.Add(1)
+		return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+	}
+
+	for i := 0; i < 5; i++ {
+		token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+			"iss": server.URL,
+			"sub": fmt.Sprintf("user%d", i),
+			"exp": time.Now().Add(time.Hour).Unix(),
+		})
+		_, err := v.ValidateAccessToken(context.Background(), token)
+		if err != nil {
+			t.Fatalf("request %d: unexpected error: %v", i, err)
+		}
+	}
+
+	if fetchCount.Load() != 1 {
+		t.Errorf("expected 1 JWKS fetch, got %d", fetchCount.Load())
+	}
+}
+
+func TestOIDCValidator_DisallowedIssuer(t *testing.T) {
+	rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+	server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, nil)
+	defer server.Close()
+
+	v := newTestValidator([]string{"https://allowed.example.com"})
+	v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+		return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+	}
+
+	token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+		"iss": "https://other.example.com",
+		"sub": "user",
+		"exp": time.Now().Add(time.Hour).Unix(),
+	})
+
+	_, err := v.ValidateAccessToken(context.Background(), token)
+	if err == nil {
+		t.Fatal("expected error for disallowed issuer")
+	}
+	authErr := err.(*AuthError)
+	if authErr.Message != "Issuer not allowed" {
+		t.Errorf("expected 'Issuer not allowed', got %s", authErr.Message)
+	}
+}
+
+func TestOIDCValidator_EmptyAllowedIssuersAcceptsAll(t *testing.T) {
+	rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+	server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, nil)
+	defer server.Close()
+
+	v := newTestValidator([]string{})
+	v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+		return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+	}
+
+	token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+		"iss": "https://any-issuer.example.com",
+		"sub": "user",
+		"exp": time.Now().Add(time.Hour).Unix(),
+	})
+
+	_, err := v.ValidateAccessToken(context.Background(), token)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+}
+
+func TestOIDCValidator_EmptyToken(t *testing.T) {
+	v := newTestValidator(nil)
+	_, err := v.ValidateAccessToken(context.Background(), "")
+	if err == nil {
+		t.Error("expected error for empty token")
+	}
+}
diff --git a/signer/internal/cert/rsa_ecdsa_signing_test.go b/signer/internal/cert/rsa_ecdsa_signing_test.go
new file mode 100644
index 000000000..233e817ea
--- /dev/null
+++ b/signer/internal/cert/rsa_ecdsa_signing_test.go
@@ -0,0 +1,210 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cert
+
+import (
+	"crypto/ecdsa"
+	"crypto/ed25519"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/pem"
+	"testing"
+
+	"golang.org/x/crypto/ssh"
+)
+
+func generateRSACA(t *testing.T) []byte {
+	t.Helper()
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatal(err)
+	}
+	privBytes, err := x509.MarshalPKCS8PrivateKey(key)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+}
+
+func generateECDSACA(t *testing.T) []byte {
+	t.Helper()
+	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	privBytes, err := x509.MarshalPKCS8PrivateKey(key)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+}
+
+func generateRSAUserKey(t *testing.T) ssh.PublicKey {
+	t.Helper()
+	key, _ := rsa.GenerateKey(rand.Reader, 2048)
+	sshPub, _ := ssh.NewPublicKey(&key.PublicKey)
+	return sshPub
+}
+
+func generateECDSAUserKey(t *testing.T) ssh.PublicKey {
+	t.Helper()
+	key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	sshPub, _ := ssh.NewPublicKey(&key.PublicKey)
+	return sshPub
+}
+
+func TestSignCertificate_RSAKeyWithRSACA(t *testing.T) {
+	caPEM := generateRSACA(t)
+	userKey := generateRSAUserKey(t)
+
+	result, err := SignCertificate(&SignRequest{
+		PublicKey:       userKey,
+		CAPrivateKeyPEM: caPEM,
+		Serial:          10,
+		Principal:       "rsauser",
+		ClientID:        "c1",
+		TTLSeconds:      3600,
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	parsedKey, err := ssh.ParsePublicKey(result.CertBytes)
+	if err != nil {
+		t.Fatalf("failed to parse cert: %v", err)
+	}
+	cert := parsedKey.(*ssh.Certificate)
+	if cert.Serial != 10 {
+		t.Errorf("expected serial 10, got %d", cert.Serial)
+	}
+}
+
+func TestSignCertificate_ECDSAKeyWithECDSACA(t *testing.T) {
+	caPEM := generateECDSACA(t)
+	userKey := generateECDSAUserKey(t)
+
+	result, err := SignCertificate(&SignRequest{
+		PublicKey:       userKey,
+		CAPrivateKeyPEM: caPEM,
+		Serial:          20,
+		Principal:       "ecuser",
+		ClientID:        "c1",
+		TTLSeconds:      3600,
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	parsedKey, err := ssh.ParsePublicKey(result.CertBytes)
+	if err != nil {
+		t.Fatalf("failed to parse cert: %v", err)
+	}
+	cert := parsedKey.(*ssh.Certificate)
+	if cert.Serial != 20 {
+		t.Errorf("expected serial 20, got %d", cert.Serial)
+	}
+}
+
+// Sign an Ed25519 user key with an RSA CA
+func TestSignCertificate_Ed25519KeyWithRSACA(t *testing.T) {
+	caPEM := generateRSACA(t)
+	pub, _, _ := ed25519.GenerateKey(rand.Reader)
+	sshPub, _ := ssh.NewPublicKey(pub)
+
+	result, err := SignCertificate(&SignRequest{
+		PublicKey:       sshPub,
+		CAPrivateKeyPEM: caPEM,
+		Serial:          30,
+		Principal:       "crossuser",
+		ClientID:        "c1",
+		TTLSeconds:      3600,
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	parsedKey, err := ssh.ParsePublicKey(result.CertBytes)
+	if err != nil {
+		t.Fatalf("failed to parse cert: %v", err)
+	}
+	cert := parsedKey.(*ssh.Certificate)
+	if cert.Serial != 30 {
+		t.Errorf("expected serial 30, got %d", cert.Serial)
+	}
+}
+
+// Sign an Ed25519 user key with ECDSA CA
+func TestSignCertificate_Ed25519KeyWithECDSACA(t *testing.T) {
+	caPEM := generateECDSACA(t)
+	pub, _, _ := ed25519.GenerateKey(rand.Reader)
+	sshPub, _ := ssh.NewPublicKey(pub)
+
+	result, err := SignCertificate(&SignRequest{
+		PublicKey:       sshPub,
+		CAPrivateKeyPEM: caPEM,
+		Serial:          40,
+		Principal:       "ecdsacauser",
+		ClientID:        "c1",
+		TTLSeconds:      3600,
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if result.Serial != 40 {
+		t.Errorf("expected serial 40, got %d", result.Serial)
+	}
+}
+
+// Test JWK conversion for RSA and ECDSA keys
+func TestPublicKeyToJWK_RSA(t *testing.T) {
+	key, _ := rsa.GenerateKey(rand.Reader, 2048)
+	pubBytes, _ := x509.MarshalPKIXPublicKey(&key.PublicKey)
+	pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+	jwkMap, err := PublicKeyToJWK(pubPEM)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if jwkMap["kty"] != "RSA" {
+		t.Errorf("expected kty RSA, got %v", jwkMap["kty"])
+	}
+	if jwkMap["n"] == nil {
+		t.Error("expected n field")
+	}
+	if jwkMap["e"] == nil {
+		t.Error("expected e field")
+	}
+}
+
+func TestPublicKeyToJWK_ECDSA(t *testing.T) {
+	key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	pubBytes, _ := x509.MarshalPKIXPublicKey(&key.PublicKey)
+	pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+	jwkMap, err := PublicKeyToJWK(pubPEM)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if jwkMap["kty"] != "EC" {
+		t.Errorf("expected kty EC, got %v", jwkMap["kty"])
+	}
+	if jwkMap["crv"] != "P-256" {
+		t.Errorf("expected crv P-256, got %v", jwkMap["crv"])
+	}
+}
diff --git a/signer/internal/cert/signer.go b/signer/internal/cert/signer.go
new file mode 100644
index 000000000..173e162a9
--- /dev/null
+++ b/signer/internal/cert/signer.go
@@ -0,0 +1,110 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cert
+
+import (
+	"crypto/rand"
+	"encoding/binary"
+	"fmt"
+	"time"
+
+	"golang.org/x/crypto/ssh"
+)
+
+type SignRequest struct {
+	PublicKey       ssh.PublicKey
+	CAPrivateKeyPEM []byte
+	Serial          uint64
+	Principal       string
+	ClientID        string
+	TTLSeconds      uint64
+	Extensions      map[string]string
+	CriticalOptions map[string]string
+}
+
+type SignResult struct {
+	CertBytes     []byte // raw certificate bytes (for base64 encoding)
+	Serial        uint64
+	ValidAfter    uint64
+	ValidBefore   uint64
+	CAFingerprint string
+	KeyID         string
+}
+
+func SignCertificate(req *SignRequest) (*SignResult, error) {
+	_, sshSigner, err := ParseCAPrivateKey(req.CAPrivateKeyPEM)
+	if err != nil {
+		return nil, fmt.Errorf("parsing CA key: %w", err)
+	}
+
+	nonce := make([]byte, 32)
+	if _, err := rand.Read(nonce); err != nil {
+		return nil, fmt.Errorf("generating nonce: %w", err)
+	}
+
+	now := time.Now().UTC()
+	validAfter := uint64(now.Unix())
+	validBefore := uint64(now.Unix()) + req.TTLSeconds
+
+	keyID := fmt.Sprintf("%s@%s-%d", req.Principal, req.ClientID, now.Unix())
+
+	extensions := req.Extensions
+	if extensions == nil {
+		extensions = map[string]string{
+			"permit-pty":             "",
+			"permit-port-forwarding": "",
+			"permit-user-rc":         "",
+		}
+	}
+
+	cert := &ssh.Certificate{
+		Nonce:           nonce,
+		Key:             req.PublicKey,
+		Serial:          req.Serial,
+		CertType:        ssh.UserCert,
+		KeyId:           keyID,
+		ValidPrincipals: []string{req.Principal},
+		ValidAfter:      validAfter,
+		ValidBefore:     validBefore,
+		Permissions: ssh.Permissions{
+			CriticalOptions: req.CriticalOptions,
+			Extensions:      extensions,
+		},
+	}
+
+	if err := cert.SignCert(rand.Reader, sshSigner); err != nil {
+		return nil, fmt.Errorf("signing certificate: %w", err)
+	}
+
+	caFingerprint := ssh.FingerprintSHA256(sshSigner.PublicKey())
+
+	return &SignResult{
+		CertBytes:     cert.Marshal(),
+		Serial:        req.Serial,
+		ValidAfter:    validAfter,
+		ValidBefore:   validBefore,
+		CAFingerprint: caFingerprint,
+		KeyID:         keyID,
+	}, nil
+}
+
+func GenerateRandomSerial() (uint64, error) {
+	var b [8]byte
+	if _, err := rand.Read(b[:]); err != nil {
+		return 0, err
+	}
+	return binary.BigEndian.Uint64(b[:]), nil
+}
diff --git a/signer/internal/cert/signer_test.go b/signer/internal/cert/signer_test.go
new file mode 100644
index 000000000..d17f26fe8
--- /dev/null
+++ b/signer/internal/cert/signer_test.go
@@ -0,0 +1,246 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cert
+
+import (
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/pem"
+	"testing"
+	"time"
+
+	"golang.org/x/crypto/ssh"
+)
+
+func generateTestEd25519CA(t *testing.T) ([]byte, ssh.PublicKey) {
+	t.Helper()
+	pub, priv, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
+	if err != nil {
+		t.Fatal(err)
+	}
+	privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+
+	sshPub, err := ssh.NewPublicKey(pub)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return privPEM, sshPub
+}
+
+func generateTestEd25519UserKey(t *testing.T) ssh.PublicKey {
+	t.Helper()
+	pub, _, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	sshPub, err := ssh.NewPublicKey(pub)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return sshPub
+}
+
+func TestSignCertificate_Ed25519(t *testing.T) {
+	caPrivPEM, _ := generateTestEd25519CA(t)
+	userKey := generateTestEd25519UserKey(t)
+
+	before := time.Now().UTC()
+	result, err := SignCertificate(&SignRequest{
+		PublicKey:       userKey,
+		CAPrivateKeyPEM: caPrivPEM,
+		Serial:          42,
+		Principal:       "testuser",
+		ClientID:        "webapp",
+		TTLSeconds:      7200,
+	})
+	after := time.Now().UTC()
+
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	// Check serial
+	if result.Serial != 42 {
+		t.Errorf("expected serial 42, got %d", result.Serial)
+	}
+
+	// Check validity window
+	if int64(result.ValidAfter) < before.Unix()-1 || int64(result.ValidAfter) > after.Unix()+1 {
+		t.Errorf("valid_after %d not within expected range [%d, %d]", result.ValidAfter, before.Unix(), after.Unix())
+	}
+	expectedBefore := result.ValidAfter + 7200
+	if result.ValidBefore != expectedBefore {
+		t.Errorf("expected valid_before %d, got %d", expectedBefore, result.ValidBefore)
+	}
+
+	// Check CA fingerprint
+	if result.CAFingerprint == "" {
+		t.Error("expected non-empty CA fingerprint")
+	}
+
+	// Parse the cert and validate internals
+	parsedKey, err := ssh.ParsePublicKey(result.CertBytes)
+	if err != nil {
+		t.Fatalf("failed to parse certificate: %v", err)
+	}
+	cert, ok := parsedKey.(*ssh.Certificate)
+	if !ok {
+		t.Fatal("parsed key is not a certificate")
+	}
+
+	if cert.Serial != 42 {
+		t.Errorf("cert serial: expected 42, got %d", cert.Serial)
+	}
+
+	if len(cert.ValidPrincipals) != 1 || cert.ValidPrincipals[0] != "testuser" {
+		t.Errorf("expected principals [testuser], got %v", cert.ValidPrincipals)
+	}
+
+	// User cert type
+	if cert.CertType != ssh.UserCert {
+		t.Errorf("expected UserCert type, got %d", cert.CertType)
+	}
+
+	for _, ext := range []string{"permit-pty", "permit-port-forwarding", "permit-user-rc"} {
+		if _, ok := cert.Extensions[ext]; !ok {
+			t.Errorf("expected extension %s", ext)
+		}
+	}
+
+	if len(cert.Nonce) != 32 {
+		t.Errorf("expected 32-byte nonce, got %d", len(cert.Nonce))
+	}
+}
+
+func TestSignCertificate_UniqueNonces(t *testing.T) {
+	caPrivPEM, _ := generateTestEd25519CA(t)
+	userKey := generateTestEd25519UserKey(t)
+
+	var nonces [][]byte
+	for i := 0; i < 10; i++ {
+		result, err := SignCertificate(&SignRequest{
+			PublicKey:       userKey,
+			CAPrivateKeyPEM: caPrivPEM,
+			Serial:          uint64(i + 1),
+			Principal:       "user",
+			ClientID:        "c1",
+			TTLSeconds:      3600,
+		})
+		if err != nil {
+			t.Fatal(err)
+		}
+		parsedKey, _ := ssh.ParsePublicKey(result.CertBytes)
+		cert := parsedKey.(*ssh.Certificate)
+		nonces = append(nonces, cert.Nonce)
+	}
+
+	// Check all nonces are unique
+	for i := 0; i < len(nonces); i++ {
+		for j := i + 1; j < len(nonces); j++ {
+			if string(nonces[i]) == string(nonces[j]) {
+				t.Errorf("nonces %d and %d are identical", i, j)
+			}
+		}
+	}
+}
+
+func TestSignCertificate_KeyIDFormat(t *testing.T) {
+	caPrivPEM, _ := generateTestEd25519CA(t)
+	userKey := generateTestEd25519UserKey(t)
+
+	result, err := SignCertificate(&SignRequest{
+		PublicKey:       userKey,
+		CAPrivateKeyPEM: caPrivPEM,
+		Serial:          1,
+		Principal:       "jdoe",
+		ClientID:        "webapp",
+		TTLSeconds:      3600,
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	parsedKey, _ := ssh.ParsePublicKey(result.CertBytes)
+	cert := parsedKey.(*ssh.Certificate)
+
+	// Key ID should match jdoe@webapp-{timestamp}
+	if len(cert.KeyId) < len("jdoe@webapp-") {
+		t.Errorf("key ID too short: %s", cert.KeyId)
+	}
+	expected := "jdoe@webapp-"
+	if cert.KeyId[:len(expected)] != expected {
+		t.Errorf("key ID prefix: expected %s, got %s", expected, cert.KeyId[:len(expected)])
+	}
+}
+
+func TestSignCertificate_WithCriticalOptions(t *testing.T) {
+	caPrivPEM, _ := generateTestEd25519CA(t)
+	userKey := generateTestEd25519UserKey(t)
+
+	result, err := SignCertificate(&SignRequest{
+		PublicKey:       userKey,
+		CAPrivateKeyPEM: caPrivPEM,
+		Serial:          1,
+		Principal:       "user",
+		ClientID:        "c1",
+		TTLSeconds:      3600,
+		CriticalOptions: map[string]string{"source-address": "10.0.0.0/8"},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	parsedKey, _ := ssh.ParsePublicKey(result.CertBytes)
+	cert := parsedKey.(*ssh.Certificate)
+
+	if v, ok := cert.Permissions.CriticalOptions["source-address"]; !ok || v != "10.0.0.0/8" {
+		t.Errorf("expected critical option source-address=10.0.0.0/8, got %v", cert.Permissions.CriticalOptions)
+	}
+}
+
+func TestSignCertificate_Base64Decodable(t *testing.T) {
+	caPrivPEM, _ := generateTestEd25519CA(t)
+	userKey := generateTestEd25519UserKey(t)
+
+	result, err := SignCertificate(&SignRequest{
+		PublicKey:       userKey,
+		CAPrivateKeyPEM: caPrivPEM,
+		Serial:          1,
+		Principal:       "user",
+		ClientID:        "c1",
+		TTLSeconds:      3600,
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	encoded := base64.StdEncoding.EncodeToString(result.CertBytes)
+	decoded, err := base64.StdEncoding.DecodeString(encoded)
+	if err != nil {
+		t.Fatalf("base64 decode failed: %v", err)
+	}
+
+	_, err = ssh.ParsePublicKey(decoded)
+	if err != nil {
+		t.Fatalf("failed to parse decoded certificate: %v", err)
+	}
+}
diff --git a/signer/internal/cert/ssh_key.go b/signer/internal/cert/ssh_key.go
new file mode 100644
index 000000000..ff1b9b118
--- /dev/null
+++ b/signer/internal/cert/ssh_key.go
@@ -0,0 +1,237 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package cert handles SSH certificate construction and signing.
+package cert
+
+import (
+	"crypto"
+	"crypto/ecdsa"
+	"crypto/ed25519"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/pem"
+	"fmt"
+	"io"
+	"strings"
+
+	"golang.org/x/crypto/ssh"
+)
+
+// ParseSSHPublicKey parses an SSH public key in authorized_keys format.
+func ParseSSHPublicKey(keyStr string) (ssh.PublicKey, error) {
+	keyStr = strings.TrimSpace(keyStr)
+	if keyStr == "" {
+		return nil, fmt.Errorf("empty public key")
+	}
+	pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyStr))
+	if err != nil {
+		return nil, fmt.Errorf("parsing SSH public key: %w", err)
+	}
+	return pubKey, nil
+}
+
+func NormalizeKeyType(sshKeyType string) string {
+	switch sshKeyType {
+	case "ssh-ed25519":
+		return "ed25519"
+	case "ssh-rsa":
+		return "rsa"
+	case "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521":
+		return "ecdsa"
+	default:
+		return sshKeyType
+	}
+}
+
+func SSHPublicKeyFingerprint(key ssh.PublicKey) string {
+	return ssh.FingerprintSHA256(key)
+}
+
+func ParseCAPrivateKey(pemData []byte) (crypto.Signer, ssh.Signer, error) {
+	block, _ := pem.Decode(pemData)
+	if block == nil {
+		return nil, nil, fmt.Errorf("failed to decode PEM block")
+	}
+
+	var signer crypto.Signer
+	switch block.Type {
+	case "PRIVATE KEY":
+		key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+		if err != nil {
+			return nil, nil, fmt.Errorf("parsing PKCS8 private key: %w", err)
+		}
+		var ok bool
+		signer, ok = key.(crypto.Signer)
+		if !ok {
+			return nil, nil, fmt.Errorf("private key does not implement crypto.Signer")
+		}
+	case "EC PRIVATE KEY":
+		key, err := x509.ParseECPrivateKey(block.Bytes)
+		if err != nil {
+			return nil, nil, fmt.Errorf("parsing EC private key: %w", err)
+		}
+		signer = key
+	case "RSA PRIVATE KEY":
+		key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
+		if err != nil {
+			return nil, nil, fmt.Errorf("parsing RSA private key: %w", err)
+		}
+		signer = key
+	default:
+		return nil, nil, fmt.Errorf("unsupported PEM block type: %s", block.Type)
+	}
+
+	var sshSigner ssh.Signer
+	var err error
+
+	// For RSA keys, use SHA-256 signer
+	if _, ok := signer.(*rsa.PrivateKey); ok {
+		sshSigner, err = ssh.NewSignerFromKey(signer)
+		if err != nil {
+			return nil, nil, fmt.Errorf("creating SSH signer: %w", err)
+		}
+		// Wrap to use rsa-sha2-256 algorithm
+		algorithmSigner, ok := sshSigner.(ssh.AlgorithmSigner)
+		if ok {
+			sshSigner = &rsaSHA256Signer{inner: algorithmSigner}
+		}
+	} else {
+		sshSigner, err = ssh.NewSignerFromKey(signer)
+		if err != nil {
+			return nil, nil, fmt.Errorf("creating SSH signer: %w", err)
+		}
+	}
+
+	return signer, sshSigner, nil
+}
+
+// rsaSHA256Signer wraps an SSH AlgorithmSigner to always use rsa-sha2-256.
+type rsaSHA256Signer struct {
+	inner ssh.AlgorithmSigner
+}
+
+func (s *rsaSHA256Signer) PublicKey() ssh.PublicKey {
+	return s.inner.PublicKey()
+}
+
+func (s *rsaSHA256Signer) Sign(rand io.Reader, data []byte) (*ssh.Signature, error) {
+	return s.inner.SignWithAlgorithm(rand, data, ssh.KeyAlgoRSASHA256)
+}
+
+func ParseCAPublicKey(pemData []byte) (ssh.PublicKey, error) {
+	block, _ := pem.Decode(pemData)
+	if block == nil {
+		return nil, fmt.Errorf("failed to decode PEM block")
+	}
+
+	pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
+	if err != nil {
+		return nil, fmt.Errorf("parsing public key: %w", err)
+	}
+
+	sshPub, err := ssh.NewPublicKey(pubKey)
+	if err != nil {
+		return nil, fmt.Errorf("converting to SSH public key: %w", err)
+	}
+	return sshPub, nil
+}
+
+// PEMToOpenSSH converts a PEM public key to OpenSSH format, suitable for trusted-user-ca-keys files.
+func PEMToOpenSSH(publicKeyPEM []byte) (string, error) {
+	sshPub, err := ParseCAPublicKey(publicKeyPEM)
+	if err != nil {
+		return "", err
+	}
+	return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub))), nil
+}
+
+func CAFingerprint(publicKeyPEM []byte) (string, error) {
+	sshPub, err := ParseCAPublicKey(publicKeyPEM)
+	if err != nil {
+		return "", err
+	}
+	return ssh.FingerprintSHA256(sshPub), nil
+}
+
+func PublicKeyToJWK(publicKeyPEM []byte) (map[string]interface{}, error) {
+	block, _ := pem.Decode(publicKeyPEM)
+	if block == nil {
+		return nil, fmt.Errorf("failed to decode PEM block")
+	}
+
+	pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
+	if err != nil {
+		return nil, fmt.Errorf("parsing public key: %w", err)
+	}
+
+	sshPub, err := ssh.NewPublicKey(pubKey)
+	if err != nil {
+		return nil, fmt.Errorf("converting to SSH public key: %w", err)
+	}
+	fingerprint := ssh.FingerprintSHA256(sshPub)
+
+	jwkMap := map[string]interface{}{
+		"kid": fingerprint,
+	}
+
+	switch key := pubKey.(type) {
+	case ed25519.PublicKey:
+		jwkMap["kty"] = "OKP"
+		jwkMap["crv"] = "Ed25519"
+		jwkMap["x"] = base64.RawURLEncoding.EncodeToString([]byte(key))
+	case *ecdsa.PublicKey:
+		jwkMap["kty"] = "EC"
+		switch key.Curve.Params().BitSize {
+		case 256:
+			jwkMap["crv"] = "P-256"
+		case 384:
+			jwkMap["crv"] = "P-384"
+		case 521:
+			jwkMap["crv"] = "P-521"
+		}
+		byteLen := (key.Curve.Params().BitSize + 7) / 8
+		xBytes := key.X.Bytes()
+		yBytes := key.Y.Bytes()
+		// Pad to full length
+		xPadded := make([]byte, byteLen)
+		yPadded := make([]byte, byteLen)
+		copy(xPadded[byteLen-len(xBytes):], xBytes)
+		copy(yPadded[byteLen-len(yBytes):], yBytes)
+		jwkMap["x"] = base64.RawURLEncoding.EncodeToString(xPadded)
+		jwkMap["y"] = base64.RawURLEncoding.EncodeToString(yPadded)
+	case *rsa.PublicKey:
+		jwkMap["kty"] = "RSA"
+		jwkMap["n"] = base64.RawURLEncoding.EncodeToString(key.N.Bytes())
+		eBytes := make([]byte, 0)
+		e := key.E
+		for e > 0 {
+			eBytes = append([]byte{byte(e & 0xff)}, eBytes...)
+			e >>= 8
+		}
+		jwkMap["e"] = base64.RawURLEncoding.EncodeToString(eBytes)
+	default:
+		return nil, fmt.Errorf("unsupported public key type")
+	}
+
+	return jwkMap, nil
+}
+
+func HashToken(token string) string {
+	h := sha256.Sum256([]byte(token))
+	return base64.StdEncoding.EncodeToString(h[:])
+}
diff --git a/signer/internal/cert/ssh_key_test.go b/signer/internal/cert/ssh_key_test.go
new file mode 100644
index 000000000..2a476b484
--- /dev/null
+++ b/signer/internal/cert/ssh_key_test.go
@@ -0,0 +1,306 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cert
+
+import (
+	"crypto/ecdsa"
+	"crypto/ed25519"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/pem"
+	"strings"
+	"testing"
+
+	"golang.org/x/crypto/ssh"
+)
+
+func TestParseSSHPublicKey_Invalid(t *testing.T) {
+	_, err := ParseSSHPublicKey("not-a-valid-ssh-key")
+	if err == nil {
+		t.Error("expected error for invalid SSH key")
+	}
+}
+
+func TestParseSSHPublicKey_Empty(t *testing.T) {
+	_, err := ParseSSHPublicKey("")
+	if err == nil {
+		t.Error("expected error for empty SSH key")
+	}
+}
+
+func TestParseSSHPublicKey_ValidEd25519(t *testing.T) {
+	pub, _, _ := ed25519.GenerateKey(rand.Reader)
+	sshPub, _ := ssh.NewPublicKey(pub)
+	authorizedKey := ssh.MarshalAuthorizedKey(sshPub)
+
+	parsed, err := ParseSSHPublicKey(string(authorizedKey))
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if parsed.Type() != "ssh-ed25519" {
+		t.Errorf("expected ssh-ed25519, got %s", parsed.Type())
+	}
+}
+
+func TestParseSSHPublicKey_ValidRSA(t *testing.T) {
+	rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+	sshPub, _ := ssh.NewPublicKey(&rsaKey.PublicKey)
+	authorizedKey := ssh.MarshalAuthorizedKey(sshPub)
+
+	parsed, err := ParseSSHPublicKey(string(authorizedKey))
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if parsed.Type() != "ssh-rsa" {
+		t.Errorf("expected ssh-rsa, got %s", parsed.Type())
+	}
+}
+
+func TestParseSSHPublicKey_ValidECDSA(t *testing.T) {
+	ecKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	sshPub, _ := ssh.NewPublicKey(&ecKey.PublicKey)
+	authorizedKey := ssh.MarshalAuthorizedKey(sshPub)
+
+	parsed, err := ParseSSHPublicKey(string(authorizedKey))
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if parsed.Type() != "ecdsa-sha2-nistp256" {
+		t.Errorf("expected ecdsa-sha2-nistp256, got %s", parsed.Type())
+	}
+}
+
+func TestNormalizeKeyType(t *testing.T) {
+	tests := []struct {
+		input    string
+		expected string
+	}{
+		{"ssh-ed25519", "ed25519"},
+		{"ssh-rsa", "rsa"},
+		{"ecdsa-sha2-nistp256", "ecdsa"},
+		{"ecdsa-sha2-nistp384", "ecdsa"},
+		{"unknown", "unknown"},
+	}
+	for _, tt := range tests {
+		got := NormalizeKeyType(tt.input)
+		if got != tt.expected {
+			t.Errorf("NormalizeKeyType(%s): expected %s, got %s", tt.input, tt.expected, got)
+		}
+	}
+}
+
+func TestHashToken(t *testing.T) {
+	hash1 := HashToken("eyJhbGciOiJSUzI1NiIs")
+	hash2 := HashToken("eyJhbGciOiJSUzI1NiIs")
+
+	// Consistent
+	if hash1 != hash2 {
+		t.Error("hash should be consistent for the same input")
+	}
+
+	// Verify it is SHA-256 base64
+	decoded, err := base64.StdEncoding.DecodeString(hash1)
+	if err != nil {
+		t.Fatalf("not valid base64: %v", err)
+	}
+	if len(decoded) != sha256.Size {
+		t.Errorf("expected %d bytes, got %d", sha256.Size, len(decoded))
+	}
+
+	// Different tokens produce different hashes
+	hash3 := HashToken("different-token")
+	if hash1 == hash3 {
+		t.Error("different tokens should produce different hashes")
+	}
+}
+
+func TestSSHPublicKeyFingerprint(t *testing.T) {
+	pub, _, _ := ed25519.GenerateKey(rand.Reader)
+	sshPub, _ := ssh.NewPublicKey(pub)
+	fp := SSHPublicKeyFingerprint(sshPub)
+	if fp == "" {
+		t.Error("expected non-empty fingerprint")
+	}
+	if len(fp) < 7 || fp[:7] != "SHA256:" {
+		t.Errorf("expected SHA256: prefix, got %s", fp)
+	}
+}
+
+func TestParseCAPrivateKey_Ed25519(t *testing.T) {
+	_, priv, _ := ed25519.GenerateKey(rand.Reader)
+	privBytes, _ := x509.MarshalPKCS8PrivateKey(priv)
+	privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+
+	signer, sshSigner, err := ParseCAPrivateKey(privPEM)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if signer == nil || sshSigner == nil {
+		t.Fatal("expected non-nil signers")
+	}
+}
+
+func TestParseCAPrivateKey_RSA(t *testing.T) {
+	rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+	privBytes, _ := x509.MarshalPKCS8PrivateKey(rsaKey)
+	privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+
+	_, sshSigner, err := ParseCAPrivateKey(privPEM)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if sshSigner == nil {
+		t.Fatal("expected non-nil SSH signer")
+	}
+}
+
+func TestParseCAPrivateKey_ECDSA(t *testing.T) {
+	ecKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	privBytes, _ := x509.MarshalPKCS8PrivateKey(ecKey)
+	privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+
+	_, sshSigner, err := ParseCAPrivateKey(privPEM)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if sshSigner == nil {
+		t.Fatal("expected non-nil SSH signer")
+	}
+}
+
+func TestParseCAPrivateKey_InvalidPEM(t *testing.T) {
+	_, _, err := ParseCAPrivateKey([]byte("not a PEM"))
+	if err == nil {
+		t.Error("expected error for invalid PEM")
+	}
+}
+
+func TestPublicKeyToJWK_Ed25519(t *testing.T) {
+	pub, _, _ := ed25519.GenerateKey(rand.Reader)
+	pubBytes, _ := x509.MarshalPKIXPublicKey(pub)
+	pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+	jwkMap, err := PublicKeyToJWK(pubPEM)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if jwkMap["kty"] != "OKP" {
+		t.Errorf("expected kty OKP, got %v", jwkMap["kty"])
+	}
+	if jwkMap["crv"] != "Ed25519" {
+		t.Errorf("expected crv Ed25519, got %v", jwkMap["crv"])
+	}
+	if jwkMap["x"] == nil || jwkMap["x"] == "" {
+		t.Error("expected non-empty x")
+	}
+	if jwkMap["kid"] == nil || jwkMap["kid"] == "" {
+		t.Error("expected non-empty kid")
+	}
+}
+
+func TestCAFingerprint(t *testing.T) {
+	pub, _, _ := ed25519.GenerateKey(rand.Reader)
+	pubBytes, _ := x509.MarshalPKIXPublicKey(pub)
+	pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+	fp, err := CAFingerprint(pubPEM)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if fp == "" || fp[:7] != "SHA256:" {
+		t.Errorf("expected SHA256: fingerprint, got %s", fp)
+	}
+}
+
+func TestPEMToOpenSSH_Ed25519(t *testing.T) {
+	pub, _, _ := ed25519.GenerateKey(rand.Reader)
+	pubBytes, _ := x509.MarshalPKIXPublicKey(pub)
+	pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+	openssh, err := PEMToOpenSSH(pubPEM)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if !strings.HasPrefix(openssh, "ssh-ed25519 ") {
+		t.Errorf("expected ssh-ed25519 prefix, got %q", openssh)
+	}
+
+	// Should be parseable back
+	parsed, err := ParseSSHPublicKey(openssh)
+	if err != nil {
+		t.Fatalf("failed to re-parse OpenSSH key: %v", err)
+	}
+	if parsed.Type() != "ssh-ed25519" {
+		t.Errorf("expected ssh-ed25519, got %s", parsed.Type())
+	}
+}
+
+func TestPEMToOpenSSH_RSA(t *testing.T) {
+	rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+	pubBytes, _ := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey)
+	pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+	openssh, err := PEMToOpenSSH(pubPEM)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if !strings.HasPrefix(openssh, "ssh-rsa ") {
+		t.Errorf("expected ssh-rsa prefix, got %q", openssh)
+	}
+}
+
+func TestPEMToOpenSSH_ECDSA(t *testing.T) {
+	ecKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	pubBytes, _ := x509.MarshalPKIXPublicKey(&ecKey.PublicKey)
+	pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+	openssh, err := PEMToOpenSSH(pubPEM)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if !strings.HasPrefix(openssh, "ecdsa-sha2-nistp256 ") {
+		t.Errorf("expected ecdsa-sha2-nistp256 prefix, got %q", openssh)
+	}
+}
+
+func TestPEMToOpenSSH_InvalidPEM(t *testing.T) {
+	_, err := PEMToOpenSSH([]byte("not valid PEM"))
+	if err == nil {
+		t.Error("expected error for invalid PEM")
+	}
+}
+
+func TestPEMToOpenSSH_NoTrailingNewline(t *testing.T) {
+	pub, _, _ := ed25519.GenerateKey(rand.Reader)
+	pubBytes, _ := x509.MarshalPKIXPublicKey(pub)
+	pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+	openssh, err := PEMToOpenSSH(pubPEM)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if strings.HasSuffix(openssh, "\n") {
+		t.Error("PEMToOpenSSH should not include trailing newline")
+	}
+}
diff --git a/signer/internal/config/config.go b/signer/internal/config/config.go
new file mode 100644
index 000000000..87736d22e
--- /dev/null
+++ b/signer/internal/config/config.go
@@ -0,0 +1,262 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package config handles YAML configuration loading with environment variable overrides.
+package config
+
+import (
+	"os"
+	"strconv"
+	"strings"
+
+	"gopkg.in/yaml.v3"
+)
+
+type Config struct {
+	Server   ServerConfig   `yaml:"server"`
+	Database DatabaseConfig `yaml:"database"`
+	Vault    VaultConfig    `yaml:"vault"`
+	Signer   SignerConfig   `yaml:"signer"`
+	DevMode  DevModeConfig  `yaml:"dev_mode"`
+	Logging  LoggingConfig  `yaml:"logging"`
+	Metrics  MetricsConfig  `yaml:"metrics"`
+	CORS     CORSConfig     `yaml:"cors"`
+}
+
+// DevModeConfig disables OIDC token validation and returns a default identity
+// for all requests when enabled.
+type DevModeConfig struct {
+	Enabled      bool   `yaml:"enabled"`
+	DefaultEmail string `yaml:"default_email"`
+}
+
+type CORSConfig struct {
+	Enabled        bool     `yaml:"enabled"`
+	AllowedOrigins []string `yaml:"allowed_origins"`
+}
+
+type ServerConfig struct {
+	Port                   int `yaml:"port"`
+	ReadTimeoutSeconds     int `yaml:"read_timeout_seconds"`
+	WriteTimeoutSeconds    int `yaml:"write_timeout_seconds"`
+	ShutdownTimeoutSeconds int `yaml:"shutdown_timeout_seconds"`
+}
+
+type DatabaseConfig struct {
+	Host                   string `yaml:"host"`
+	Port                   int    `yaml:"port"`
+	Name                   string `yaml:"name"`
+	Username               string `yaml:"username"`
+	Password               string `yaml:"password"`
+	MaxOpenConns           int    `yaml:"max_open_conns"`
+	MaxIdleConns           int    `yaml:"max_idle_conns"`
+	ConnMaxLifetimeSeconds int    `yaml:"conn_max_lifetime_seconds"`
+}
+
+type VaultConfig struct {
+	Address        string `yaml:"address"`
+	Token          string `yaml:"token"`
+	MountPath      string `yaml:"mount_path"`
+	TimeoutSeconds int    `yaml:"timeout_seconds"`
+}
+
+type SignerConfig struct {
+	CA         CAConfig         `yaml:"ca"`
+	Policy     PolicyConfig     `yaml:"policy"`
+	Auth       AuthConfig       `yaml:"auth"`
+	Validation ValidationConfig `yaml:"validation"`
+}
+
+type CAConfig struct {
+	Rotation RotationConfig `yaml:"rotation"`
+}
+
+type RotationConfig struct {
+	PeriodHours  int `yaml:"period_hours"`
+	OverlapHours int `yaml:"overlap_hours"`
+}
+
+type PolicyConfig struct {
+	Defaults PolicyDefaults `yaml:"defaults"`
+}
+
+type PolicyDefaults struct {
+	MaxTTLSeconds   int      `yaml:"max_ttl_seconds"`
+	AllowedKeyTypes []string `yaml:"allowed_key_types"`
+}
+
+type AuthConfig struct {
+	AllowedIssuers []string   `yaml:"allowed_issuers"`
+	OIDC           OIDCConfig `yaml:"oidc"`
+}
+
+type OIDCConfig struct {
+	JWKSCacheTTLSeconds int `yaml:"jwks_cache_ttl_seconds"`
+	JWKSMaxProviders    int `yaml:"jwks_max_providers"`
+	TimeoutSeconds      int `yaml:"timeout_seconds"`
+}
+
+type ValidationConfig struct {
+	PrincipalValidator string         `yaml:"principal_validator"`
+	COmanage           COmanageConfig `yaml:"comanage"`
+}
+
+type COmanageConfig struct {
+	RegistryURL    string `yaml:"registry_url"`
+	APIPath        string `yaml:"api_path"`
+	TimeoutSeconds int    `yaml:"timeout_seconds"`
+	VerifySSL      bool   `yaml:"verify_ssl"`
+}
+
+type LoggingConfig struct {
+	Level      string `yaml:"level"`
+	Format     string `yaml:"format"`
+	AuditLevel string `yaml:"audit_level"`
+}
+
+type MetricsConfig struct {
+	Enabled bool   `yaml:"enabled"`
+	Path    string `yaml:"path"`
+}
+
+func DefaultConfig() *Config {
+	return &Config{
+		Server: ServerConfig{
+			Port:                   8084,
+			ReadTimeoutSeconds:     30,
+			WriteTimeoutSeconds:    30,
+			ShutdownTimeoutSeconds: 30,
+		},
+		Database: DatabaseConfig{
+			Host:                   "localhost",
+			Port:                   3306,
+			Name:                   "custos_signer",
+			Username:               "admin",
+			Password:               "admin",
+			MaxOpenConns:           25,
+			MaxIdleConns:           5,
+			ConnMaxLifetimeSeconds: 300,
+		},
+		Vault: VaultConfig{
+			Address:        "http://localhost:8200",
+			Token:          "",
+			MountPath:      "ssh-ca",
+			TimeoutSeconds: 10,
+		},
+		Signer: SignerConfig{
+			CA: CAConfig{
+				Rotation: RotationConfig{
+					PeriodHours:  2160,
+					OverlapHours: 2,
+				},
+			},
+			Policy: PolicyConfig{
+				Defaults: PolicyDefaults{
+					MaxTTLSeconds:   86400,
+					AllowedKeyTypes: []string{"ed25519", "rsa", "ecdsa"},
+				},
+			},
+			Auth: AuthConfig{
+				AllowedIssuers: []string{},
+				OIDC: OIDCConfig{
+					JWKSCacheTTLSeconds: 300,
+					JWKSMaxProviders:    10,
+					TimeoutSeconds:      10,
+				},
+			},
+			Validation: ValidationConfig{
+				PrincipalValidator: "noop",
+				COmanage: COmanageConfig{
+					APIPath:        "/registry/co_people.json",
+					TimeoutSeconds: 10,
+					VerifySSL:      true,
+				},
+			},
+		},
+		Logging: LoggingConfig{
+			Level:      "info",
+			Format:     "json",
+			AuditLevel: "info",
+		},
+		Metrics: MetricsConfig{
+			Enabled: true,
+			Path:    "/metrics",
+		},
+	}
+}
+
+// Load reads configuration from a YAML file, then applies environment variable overrides.
+func Load(path string) (*Config, error) {
+	cfg := DefaultConfig()
+
+	if path != "" {
+		data, err := os.ReadFile(path)
+		if err != nil {
+			return nil, err
+		}
+		if err := yaml.Unmarshal(data, cfg); err != nil {
+			return nil, err
+		}
+	}
+
+	applyEnvOverrides(cfg)
+	return cfg, nil
+}
+
+func applyEnvOverrides(cfg *Config) {
+	if v := os.Getenv("DB_HOST"); v != "" {
+		cfg.Database.Host = v
+	}
+	if v := os.Getenv("DB_PORT"); v != "" {
+		if p, err := strconv.Atoi(v); err == nil {
+			cfg.Database.Port = p
+		}
+	}
+	if v := os.Getenv("DB_NAME"); v != "" {
+		cfg.Database.Name = v
+	}
+	if v := os.Getenv("DB_USERNAME"); v != "" {
+		cfg.Database.Username = v
+	}
+	if v := os.Getenv("DB_PASSWORD"); v != "" {
+		cfg.Database.Password = v
+	}
+	if v := os.Getenv("VAULT_ADDRESS"); v != "" {
+		cfg.Vault.Address = v
+	}
+	if v := os.Getenv("VAULT_TOKEN"); v != "" {
+		cfg.Vault.Token = v
+	}
+	if v := os.Getenv("DEV_MODE"); v != "" {
+		cfg.DevMode.Enabled = strings.EqualFold(v, "true")
+	}
+	if v := os.Getenv("DEV_DEFAULT_EMAIL"); v != "" {
+		cfg.DevMode.DefaultEmail = v
+	}
+	if v := os.Getenv("ALLOWED_ISSUERS"); v != "" {
+		issuers := strings.Split(v, ",")
+		trimmed := make([]string, 0, len(issuers))
+		for _, issuer := range issuers {
+			s := strings.TrimSpace(issuer)
+			if s != "" {
+				trimmed = append(trimmed, s)
+			}
+		}
+		cfg.Signer.Auth.AllowedIssuers = trimmed
+	}
+	if v := os.Getenv("LOG_LEVEL"); v != "" {
+		cfg.Logging.Level = v
+	}
+}
diff --git a/signer/internal/config/config_test.go b/signer/internal/config/config_test.go
new file mode 100644
index 000000000..f7a3e35fa
--- /dev/null
+++ b/signer/internal/config/config_test.go
@@ -0,0 +1,122 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestLoad_DefaultConfig(t *testing.T) {
+	cfg, err := Load("")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if cfg.Server.Port != 8084 {
+		t.Errorf("expected port 8084, got %d", cfg.Server.Port)
+	}
+	if cfg.Database.Host != "localhost" {
+		t.Errorf("expected host localhost, got %s", cfg.Database.Host)
+	}
+	if cfg.Signer.Validation.PrincipalValidator != "noop" {
+		t.Errorf("expected noop validator, got %s", cfg.Signer.Validation.PrincipalValidator)
+	}
+}
+
+func TestLoad_YAMLFile(t *testing.T) {
+	yaml := `
+server:
+  port: 9090
+database:
+  host: dbhost
+  port: 3307
+  password: file_password
+`
+	tmpDir := t.TempDir()
+	path := filepath.Join(tmpDir, "config.yaml")
+	if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
+		t.Fatal(err)
+	}
+
+	cfg, err := Load(path)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if cfg.Server.Port != 9090 {
+		t.Errorf("expected port 9090, got %d", cfg.Server.Port)
+	}
+	if cfg.Database.Host != "dbhost" {
+		t.Errorf("expected host dbhost, got %s", cfg.Database.Host)
+	}
+	if cfg.Database.Password != "file_password" {
+		t.Errorf("expected password file_password, got %s", cfg.Database.Password)
+	}
+}
+
+func TestLoad_EnvOverridesYAML(t *testing.T) {
+	yaml := `
+database:
+  password: file_password
+  host: filehost
+`
+	tmpDir := t.TempDir()
+	path := filepath.Join(tmpDir, "config.yaml")
+	if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
+		t.Fatal(err)
+	}
+
+	t.Setenv("DB_PASSWORD", "env_password")
+	t.Setenv("DB_HOST", "envhost")
+	t.Setenv("VAULT_ADDRESS", "http://vault:8200")
+	t.Setenv("DEV_MODE", "true")
+	t.Setenv("DEV_DEFAULT_EMAIL", "test@dev.local")
+	t.Setenv("ALLOWED_ISSUERS", "https://a.com, https://b.com")
+	t.Setenv("LOG_LEVEL", "debug")
+
+	cfg, err := Load(path)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if cfg.Database.Password != "env_password" {
+		t.Errorf("expected env_password, got %s", cfg.Database.Password)
+	}
+	if cfg.Database.Host != "envhost" {
+		t.Errorf("expected envhost, got %s", cfg.Database.Host)
+	}
+	if cfg.Vault.Address != "http://vault:8200" {
+		t.Errorf("expected vault address override, got %s", cfg.Vault.Address)
+	}
+	if !cfg.DevMode.Enabled {
+		t.Error("expected dev mode enabled via env")
+	}
+	if cfg.DevMode.DefaultEmail != "test@dev.local" {
+		t.Errorf("expected default email 'test@dev.local', got %s", cfg.DevMode.DefaultEmail)
+	}
+	if len(cfg.Signer.Auth.AllowedIssuers) != 2 {
+		t.Errorf("expected 2 issuers, got %d", len(cfg.Signer.Auth.AllowedIssuers))
+	}
+	if cfg.Logging.Level != "debug" {
+		t.Errorf("expected debug log level, got %s", cfg.Logging.Level)
+	}
+}
+
+func TestLoad_FileNotFound(t *testing.T) {
+	_, err := Load("/nonexistent/config.yaml")
+	if err == nil {
+		t.Error("expected error for missing config file")
+	}
+}
diff --git a/signer/internal/handler/admin.go b/signer/internal/handler/admin.go
new file mode 100644
index 000000000..63eca1a88
--- /dev/null
+++ b/signer/internal/handler/admin.go
@@ -0,0 +1,88 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"time"
+
+	"github.com/apache/airavata-custos/signer/internal/cert"
+	"github.com/apache/airavata-custos/signer/internal/httputil"
+	vaultpkg "github.com/apache/airavata-custos/signer/internal/vault"
+)
+
+type RotateCAResponse struct {
+	Success              bool   `json:"success"`
+	CurrentCAFingerprint string `json:"current_ca_fingerprint"`
+	NextCAFingerprint    string `json:"next_ca_fingerprint"`
+	RotationTimestamp    int64  `json:"rotation_timestamp"`
+}
+
+type AdminHandler struct {
+	vaultClient *vaultpkg.Client
+	logger      *slog.Logger
+}
+
+func NewAdminHandler(vaultClient *vaultpkg.Client, logger *slog.Logger) *AdminHandler {
+	return &AdminHandler{
+		vaultClient: vaultClient,
+		logger:      logger,
+	}
+}
+
+func (h *AdminHandler) Handle(w http.ResponseWriter, r *http.Request) {
+	clientCfg := httputil.ClientConfigFromContext(r.Context())
+	if clientCfg == nil {
+		writeError(w, http.StatusInternalServerError, "internal_error", "Missing client config")
+		return
+	}
+
+	tenantID := clientCfg.TenantID
+	clientID := clientCfg.ClientID
+
+	_, _, err := h.vaultClient.RotateCA(r.Context(), tenantID, clientID)
+	if err != nil {
+		h.logger.Error("failed to rotate CA", "error", err, "tenant_id", tenantID, "client_id", clientID)
+		writeError(w, http.StatusInternalServerError, "internal_error", "Failed to rotate CA keys")
+		return
+	}
+
+	currentKey, err := h.vaultClient.GetCurrentCAKey(r.Context(), tenantID, clientID)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read rotated keys")
+		return
+	}
+	currentFP, _ := cert.CAFingerprint(currentKey.PublicKey)
+
+	nextKey, err := h.vaultClient.GetNextCAKey(r.Context(), tenantID, clientID)
+	nextFP := ""
+	if err == nil && nextKey != nil {
+		nextFP, _ = cert.CAFingerprint(nextKey.PublicKey)
+	}
+
+	resp := RotateCAResponse{
+		Success:              true,
+		CurrentCAFingerprint: currentFP,
+		NextCAFingerprint:    nextFP,
+		RotationTimestamp:    time.Now().UTC().Unix(),
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusOK)
+	json.NewEncoder(w).Encode(resp)
+}
diff --git a/signer/internal/handler/ca_public_key.go b/signer/internal/handler/ca_public_key.go
new file mode 100644
index 000000000..760c83d02
--- /dev/null
+++ b/signer/internal/handler/ca_public_key.go
@@ -0,0 +1,93 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"strings"
+
+	"github.com/apache/airavata-custos/signer/internal/cert"
+	"github.com/apache/airavata-custos/signer/internal/httputil"
+	vaultpkg "github.com/apache/airavata-custos/signer/internal/vault"
+)
+
+type CAPublicKeyJSONResponse struct {
+	PublicKey   string `json:"public_key"`
+	Fingerprint string `json:"fingerprint"`
+	Algorithm   string `json:"algorithm"`
+}
+
+type CAPublicKeyHandler struct {
+	vaultClient *vaultpkg.Client
+	logger      *slog.Logger
+}
+
+func NewCAPublicKeyHandler(vaultClient *vaultpkg.Client, logger *slog.Logger) *CAPublicKeyHandler {
+	return &CAPublicKeyHandler{
+		vaultClient: vaultClient,
+		logger:      logger,
+	}
+}
+
+func (h *CAPublicKeyHandler) Handle(w http.ResponseWriter, r *http.Request) {
+	clientCfg := httputil.ClientConfigFromContext(r.Context())
+	if clientCfg == nil {
+		writeError(w, http.StatusInternalServerError, "internal_error", "Missing client config")
+		return
+	}
+
+	tenantID := clientCfg.TenantID
+	clientID := clientCfg.ClientID
+
+	currentKey, err := h.vaultClient.GetCurrentCAKey(r.Context(), tenantID, clientID)
+	if err != nil {
+		h.logger.Error("failed to get current CA key", "error", err)
+		writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve CA key")
+		return
+	}
+
+	opensshKey, err := cert.PEMToOpenSSH(currentKey.PublicKey)
+	if err != nil {
+		h.logger.Error("failed to convert CA key to OpenSSH format", "error", err)
+		writeError(w, http.StatusInternalServerError, "internal_error", "Failed to convert CA key")
+		return
+	}
+
+	// If the client accepts JSON, return structured response
+	accept := r.Header.Get("Accept")
+	if strings.Contains(accept, "application/json") {
+		fingerprint, _ := cert.CAFingerprint(currentKey.PublicKey)
+		algorithm := cert.NormalizeKeyType(strings.SplitN(opensshKey, " ", 2)[0])
+
+		resp := CAPublicKeyJSONResponse{
+			PublicKey:   opensshKey,
+			Fingerprint: fingerprint,
+			Algorithm:   algorithm,
+		}
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		json.NewEncoder(w).Encode(resp)
+		return
+	}
+
+	// Default: plain text for direct piping to trusted-user-ca-keys
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	w.WriteHeader(http.StatusOK)
+	w.Write([]byte(opensshKey + "\n"))
+}
diff --git a/signer/internal/handler/ca_public_key_test.go b/signer/internal/handler/ca_public_key_test.go
new file mode 100644
index 000000000..2a41fb03e
--- /dev/null
+++ b/signer/internal/handler/ca_public_key_test.go
@@ -0,0 +1,78 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestCAPublicKeyHandler_MissingClientConfig(t *testing.T) {
+	h := NewCAPublicKeyHandler(nil, nil)
+
+	req := httptest.NewRequest("GET", "/api/v1/ca-public-key", nil)
+	rec := httptest.NewRecorder()
+	h.Handle(rec, req)
+
+	if rec.Code != http.StatusInternalServerError {
+		t.Errorf("expected 500, got %d", rec.Code)
+	}
+
+	var body map[string]string
+	json.NewDecoder(rec.Body).Decode(&body)
+	if body["error"] != "internal_error" {
+		t.Errorf("expected error 'internal_error', got %q", body["error"])
+	}
+	if body["message"] != "Missing client config" {
+		t.Errorf("expected message 'Missing client config', got %q", body["message"])
+	}
+}
+
+func TestCAPublicKeyJSONResponse_Format(t *testing.T) {
+	resp := CAPublicKeyJSONResponse{
+		PublicKey:   "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample",
+		Fingerprint: "SHA256:abcdef123456",
+		Algorithm:   "ed25519",
+	}
+
+	data, err := json.Marshal(resp)
+	if err != nil {
+		t.Fatalf("marshal: %v", err)
+	}
+
+	var parsed map[string]interface{}
+	if err := json.Unmarshal(data, &parsed); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+
+	for _, key := range []string{"public_key", "fingerprint", "algorithm"} {
+		if _, ok := parsed[key]; !ok {
+			t.Errorf("missing key %q in JSON output", key)
+		}
+	}
+
+	if parsed["public_key"] != "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample" {
+		t.Errorf("unexpected public_key: %v", parsed["public_key"])
+	}
+	if parsed["fingerprint"] != "SHA256:abcdef123456" {
+		t.Errorf("unexpected fingerprint: %v", parsed["fingerprint"])
+	}
+	if parsed["algorithm"] != "ed25519" {
+		t.Errorf("unexpected algorithm: %v", parsed["algorithm"])
+	}
+}
diff --git a/signer/internal/handler/certificates.go b/signer/internal/handler/certificates.go
new file mode 100644
index 000000000..19f7ef727
--- /dev/null
+++ b/signer/internal/handler/certificates.go
@@ -0,0 +1,179 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+	"database/sql"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"github.com/apache/airavata-custos/signer/internal/httputil"
+	"github.com/apache/airavata-custos/signer/internal/store"
+)
+
+type CertificateResponse struct {
+	SerialNumber         int64  `json:"serial_number"`
+	KeyID                string `json:"key_id"`
+	Principal            string `json:"principal"`
+	PublicKeyFingerprint string `json:"public_key_fingerprint"`
+	CAFingerprint        string `json:"ca_fingerprint"`
+	ValidAfter           int64  `json:"valid_after"`
+	ValidBefore          int64  `json:"valid_before"`
+	IssuedAt             int64  `json:"issued_at"`
+	SourceIP             string `json:"source_ip,omitempty"`
+	Revoked              bool   `json:"revoked"`
+	RevokedAt            *int64 `json:"revoked_at,omitempty"`
+	RevocationReason     string `json:"revocation_reason,omitempty"`
+}
+
+type CertificateListResponse struct {
+	Certificates []CertificateResponse `json:"certificates"`
+	Total        int                   `json:"total"`
+	Limit        int                   `json:"limit"`
+	Offset       int                   `json:"offset"`
+}
+
+type CertificatesHandler struct {
+	db     *store.DB
+	logger *slog.Logger
+}
+
+func NewCertificatesHandler(db *store.DB, logger *slog.Logger) *CertificatesHandler {
+	return &CertificatesHandler{
+		db:     db,
+		logger: logger,
+	}
+}
+
+func (h *CertificatesHandler) HandleList(w http.ResponseWriter, r *http.Request) {
+	identity := httputil.UserIdentityFromContext(r.Context())
+	if identity == nil {
+		writeError(w, http.StatusUnauthorized, "unauthorized", "Missing user identity")
+		return
+	}
+
+	limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
+	offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+
+	if limit <= 0 {
+		limit = 20
+	}
+
+	result, err := h.db.ListCertificatesByEmail(r.Context(), identity.Email, limit, offset)
+	if err != nil {
+		h.logger.Error("failed to list certificates", "error", err, "email", identity.Email)
+		writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list certificates")
+		return
+	}
+
+	certs := make([]CertificateResponse, 0, len(result.Certificates))
+	for _, c := range result.Certificates {
+		cert := CertificateResponse{
+			SerialNumber:         c.SerialNumber,
+			KeyID:                c.KeyID,
+			Principal:            c.Principal,
+			PublicKeyFingerprint: c.PublicKeyFingerprint,
+			CAFingerprint:        c.CAFingerprint,
+			ValidAfter:           c.ValidAfter.Unix(),
+			ValidBefore:          c.ValidBefore.Unix(),
+			IssuedAt:             c.IssuedAt.Unix(),
+			SourceIP:             c.SourceIP,
+			Revoked:              c.Revoked,
+			RevocationReason:     c.RevocationReason,
+		}
+		if c.RevokedAt != nil {
+			ts := c.RevokedAt.Unix()
+			cert.RevokedAt = &ts
+		}
+		certs = append(certs, cert)
+	}
+
+	resp := CertificateListResponse{
+		Certificates: certs,
+		Total:        result.Total,
+		Limit:        limit,
+		Offset:       offset,
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusOK)
+	json.NewEncoder(w).Encode(resp)
+}
+
+// HandleGet expects the serial number as the last URL path segment.
+func (h *CertificatesHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
+	identity := httputil.UserIdentityFromContext(r.Context())
+	if identity == nil {
+		writeError(w, http.StatusUnauthorized, "unauthorized", "Missing user identity")
+		return
+	}
+
+	parts := strings.Split(strings.TrimRight(r.URL.Path, "/"), "/")
+	serialStr := parts[len(parts)-1]
+
+	serial, err := strconv.ParseInt(serialStr, 10, 64)
+	if err != nil {
+		writeError(w, http.StatusBadRequest, "invalid_request", "Invalid serial number")
+		return
+	}
+
+	cert, err := h.db.GetCertificateBySerial(r.Context(), serial)
+	if err != nil {
+		if err == sql.ErrNoRows || strings.Contains(err.Error(), "no rows") {
+			writeError(w, http.StatusNotFound, "not_found", "Certificate not found")
+			return
+		}
+		h.logger.Error("failed to get certificate", "error", err, "serial", serial)
+		writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get certificate")
+		return
+	}
+
+	// Authorization: user can only view their own certificates
+	if cert.UserEmail != identity.Email {
+		writeError(w, http.StatusForbidden, "forbidden", "You can only view your own certificates")
+		return
+	}
+
+	resp := toCertificateResponse(cert)
+
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusOK)
+	json.NewEncoder(w).Encode(resp)
+}
+
+func toCertificateResponse(c *store.CertificateWithStatus) CertificateResponse {
+	resp := CertificateResponse{
+		SerialNumber:         c.SerialNumber,
+		KeyID:                c.KeyID,
+		Principal:            c.Principal,
+		PublicKeyFingerprint: c.PublicKeyFingerprint,
+		CAFingerprint:        c.CAFingerprint,
+		ValidAfter:           c.ValidAfter.Unix(),
+		ValidBefore:          c.ValidBefore.Unix(),
+		IssuedAt:             c.IssuedAt.Unix(),
+		SourceIP:             c.SourceIP,
+		Revoked:              c.Revoked,
+		RevocationReason:     c.RevocationReason,
+	}
+	if c.RevokedAt != nil {
+		ts := c.RevokedAt.Unix()
+		resp.RevokedAt = &ts
+	}
+	return resp
+}
diff --git a/signer/internal/handler/certificates_test.go b/signer/internal/handler/certificates_test.go
new file mode 100644
index 000000000..49463f663
--- /dev/null
+++ b/signer/internal/handler/certificates_test.go
@@ -0,0 +1,238 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/apache/airavata-custos/signer/internal/httputil"
+	"github.com/apache/airavata-custos/signer/internal/store"
+)
+
+func TestCertificatesHandler_MissingIdentity(t *testing.T) {
+	h := NewCertificatesHandler(nil, nil)
+
+	req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+	rec := httptest.NewRecorder()
+	h.HandleList(rec, req)
+
+	if rec.Code != http.StatusUnauthorized {
+		t.Errorf("expected 401, got %d", rec.Code)
+	}
+
+	var body map[string]string
+	json.NewDecoder(rec.Body).Decode(&body)
+	if body["error"] != "unauthorized" {
+		t.Errorf("expected error 'unauthorized', got %q", body["error"])
+	}
+}
+
+func TestCertificatesHandler_WithIdentity_NilDB(t *testing.T) {
+	// When identity is present but DB is nil, the handler should return 500
+	// (this tests the error path -in production DB would never be nil)
+	h := NewCertificatesHandler(nil, nil)
+
+	req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+	ctx := httputil.WithUserIdentity(req.Context(), &httputil.UserIdentityContext{
+		Issuer:    "https://cilogon.org",
+		Subject:   "testuser",
+		Principal: "testuser",
+	})
+	req = req.WithContext(ctx)
+
+	rec := httptest.NewRecorder()
+
+	// This will panic due to nil DB -recover and check
+	defer func() {
+		if r := recover(); r != nil {
+			// Expected: nil pointer dereference on DB
+			// This confirms the handler correctly reads the identity from context
+			// before attempting DB access
+		}
+	}()
+	h.HandleList(rec, req)
+}
+
+func TestCertificateListResponse_JSONFormat(t *testing.T) {
+	resp := CertificateListResponse{
+		Certificates: []CertificateResponse{
+			{
+				SerialNumber:         42,
+				KeyID:                "user@client-42",
+				Principal:            "testuser",
+				PublicKeyFingerprint: "SHA256:abc123",
+				CAFingerprint:        "SHA256:def456",
+				ValidAfter:           1709251200,
+				ValidBefore:          1709337600,
+				IssuedAt:             1709251200,
+				Revoked:              false,
+			},
+		},
+		Total:  1,
+		Limit:  20,
+		Offset: 0,
+	}
+
+	data, err := json.Marshal(resp)
+	if err != nil {
+		t.Fatalf("failed to marshal response: %v", err)
+	}
+
+	var parsed map[string]interface{}
+	if err := json.Unmarshal(data, &parsed); err != nil {
+		t.Fatalf("failed to unmarshal response: %v", err)
+	}
+
+	if parsed["total"].(float64) != 1 {
+		t.Errorf("expected total=1, got %v", parsed["total"])
+	}
+	if parsed["limit"].(float64) != 20 {
+		t.Errorf("expected limit=20, got %v", parsed["limit"])
+	}
+
+	certs := parsed["certificates"].([]interface{})
+	if len(certs) != 1 {
+		t.Errorf("expected 1 certificate, got %d", len(certs))
+	}
+
+	cert := certs[0].(map[string]interface{})
+	if cert["serial_number"].(float64) != 42 {
+		t.Errorf("expected serial_number=42, got %v", cert["serial_number"])
+	}
+	if cert["revoked"].(bool) != false {
+		t.Errorf("expected revoked=false, got %v", cert["revoked"])
+	}
+}
+
+func TestCertificateDetailHandler_MissingIdentity(t *testing.T) {
+	h := NewCertificatesHandler(nil, nil)
+
+	req := httptest.NewRequest("GET", "/api/v1/certificates/42", nil)
+	rec := httptest.NewRecorder()
+	h.HandleGet(rec, req)
+
+	if rec.Code != http.StatusUnauthorized {
+		t.Errorf("expected 401, got %d", rec.Code)
+	}
+}
+
+func TestCertificateDetailHandler_InvalidSerial(t *testing.T) {
+	h := NewCertificatesHandler(nil, nil)
+
+	req := httptest.NewRequest("GET", "/api/v1/certificates/abc", nil)
+	ctx := httputil.WithUserIdentity(req.Context(), &httputil.UserIdentityContext{
+		Subject: "testuser",
+	})
+	req = req.WithContext(ctx)
+	rec := httptest.NewRecorder()
+	h.HandleGet(rec, req)
+
+	if rec.Code != http.StatusBadRequest {
+		t.Errorf("expected 400, got %d", rec.Code)
+	}
+
+	var body map[string]string
+	json.NewDecoder(rec.Body).Decode(&body)
+	if body["error"] != "invalid_request" {
+		t.Errorf("expected error 'invalid_request', got %q", body["error"])
+	}
+}
+
+func TestToCertificateResponse(t *testing.T) {
+	now := int64(1709251200)
+	revokedAt := int64(1709337600)
+
+	tests := []struct {
+		name    string
+		revoked bool
+		wantRA  bool
+	}{
+		{"not revoked", false, false},
+		{"revoked", true, true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			c := &store.CertificateWithStatus{
+				SerialNumber:         42,
+				KeyID:                "user@client-42",
+				Principal:            "testuser",
+				PublicKeyFingerprint: "SHA256:abc",
+				CAFingerprint:        "SHA256:def",
+				ValidAfter:           time.Unix(now, 0),
+				ValidBefore:          time.Unix(now+86400, 0),
+				IssuedAt:             time.Unix(now, 0),
+				Revoked:              tt.revoked,
+			}
+			if tt.revoked {
+				ra := time.Unix(revokedAt, 0)
+				c.RevokedAt = &ra
+				c.RevocationReason = "compromised"
+			}
+
+			resp := toCertificateResponse(c)
+			if resp.SerialNumber != 42 {
+				t.Errorf("serial: got %d, want 42", resp.SerialNumber)
+			}
+			if resp.Revoked != tt.revoked {
+				t.Errorf("revoked: got %v, want %v", resp.Revoked, tt.revoked)
+			}
+			if tt.wantRA && resp.RevokedAt == nil {
+				t.Error("expected non-nil RevokedAt")
+			}
+			if !tt.wantRA && resp.RevokedAt != nil {
+				t.Error("expected nil RevokedAt")
+			}
+		})
+	}
+}
+
+func TestUserIdentityContext_RoundTrip(t *testing.T) {
+	req := httptest.NewRequest("GET", "/", nil)
+
+	// Before setting identity
+	if id := httputil.UserIdentityFromContext(req.Context()); id != nil {
+		t.Error("expected nil identity from empty context")
+	}
+
+	// Set identity
+	identity := &httputil.UserIdentityContext{
+		Issuer:    "https://cilogon.org",
+		Subject:   "http://cilogon.org/serverA/users/12345",
+		Principal: "jdoe",
+	}
+	ctx := httputil.WithUserIdentity(req.Context(), identity)
+	req = req.WithContext(ctx)
+
+	// Retrieve
+	got := httputil.UserIdentityFromContext(req.Context())
+	if got == nil {
+		t.Fatal("expected identity, got nil")
+	}
+	if got.Subject != identity.Subject {
+		t.Errorf("subject: expected %q, got %q", identity.Subject, got.Subject)
+	}
+	if got.Issuer != identity.Issuer {
+		t.Errorf("issuer: expected %q, got %q", identity.Issuer, got.Issuer)
+	}
+	if got.Principal != identity.Principal {
+		t.Errorf("principal: expected %q, got %q", identity.Principal, got.Principal)
+	}
+}
diff --git a/signer/internal/handler/common.go b/signer/internal/handler/common.go
new file mode 100644
index 000000000..e8ac579f5
--- /dev/null
+++ b/signer/internal/handler/common.go
@@ -0,0 +1,26 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+	"net/http"
+
+	"github.com/apache/airavata-custos/signer/internal/httputil"
+)
+
+func writeError(w http.ResponseWriter, status int, code, message string) {
+	httputil.WriteJSONError(w, status, code, message)
+}
diff --git a/signer/internal/handler/health.go b/signer/internal/handler/health.go
new file mode 100644
index 000000000..5adb7e190
--- /dev/null
+++ b/signer/internal/handler/health.go
@@ -0,0 +1,119 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"time"
+
+	"github.com/apache/airavata-custos/signer/internal/store"
+	vaultpkg "github.com/apache/airavata-custos/signer/internal/vault"
+)
+
+type HealthResponse struct {
+	Status string                        `json:"status"`
+	Checks map[string]*HealthCheckResult `json:"checks"`
+}
+
+type HealthCheckResult struct {
+	Status    string `json:"status"`
+	LatencyMs int64  `json:"latency_ms,omitempty"`
+	Error     string `json:"error,omitempty"`
+}
+
+type HealthHandler struct {
+	db          *store.DB
+	vaultClient *vaultpkg.Client
+}
+
+func NewHealthHandler(db *store.DB, vaultClient *vaultpkg.Client) *HealthHandler {
+	return &HealthHandler{
+		db:          db,
+		vaultClient: vaultClient,
+	}
+}
+
+func (h *HealthHandler) Handle(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	checks := make(map[string]*HealthCheckResult)
+	healthy := true
+
+	dbResult := h.checkDatabase(ctx)
+	checks["database"] = dbResult
+	if dbResult.Status != "up" {
+		healthy = false
+	}
+
+	vaultResult := h.checkVault(ctx)
+	checks["vault"] = vaultResult
+	if vaultResult.Status != "up" {
+		healthy = false
+	}
+
+	status := "healthy"
+	httpStatus := http.StatusOK
+	if !healthy {
+		status = "unhealthy"
+		httpStatus = http.StatusServiceUnavailable
+	}
+
+	resp := HealthResponse{
+		Status: status,
+		Checks: checks,
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(httpStatus)
+	json.NewEncoder(w).Encode(resp)
+}
+
+func (h *HealthHandler) checkDatabase(ctx context.Context) *HealthCheckResult {
+	start := time.Now()
+	err := h.db.Ping()
+	latency := time.Since(start).Milliseconds()
+
+	if err != nil {
+		return &HealthCheckResult{
+			Status:    "down",
+			LatencyMs: latency,
+			Error:     "database connection failed",
+		}
+	}
+	return &HealthCheckResult{
+		Status:    "up",
+		LatencyMs: latency,
+	}
+}
+
+func (h *HealthHandler) checkVault(ctx context.Context) *HealthCheckResult {
+	start := time.Now()
+	err := h.vaultClient.Healthy(ctx)
+	latency := time.Since(start).Milliseconds()
+
+	if err != nil {
+		return &HealthCheckResult{
+			Status:    "down",
+			LatencyMs: latency,
+			Error:     "vault connection failed",
+		}
+	}
+	return &HealthCheckResult{
+		Status:    "up",
+		LatencyMs: latency,
+	}
+}
diff --git a/signer/internal/handler/jwks.go b/signer/internal/handler/jwks.go
new file mode 100644
index 000000000..46fc15371
--- /dev/null
+++ b/signer/internal/handler/jwks.go
@@ -0,0 +1,102 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"time"
+
+	"github.com/apache/airavata-custos/signer/internal/cert"
+	"github.com/apache/airavata-custos/signer/internal/httputil"
+	vaultpkg "github.com/apache/airavata-custos/signer/internal/vault"
+)
+
+type JWKSResponse struct {
+	CurrentKey          interface{} `json:"current_key"`
+	NextKey             interface{} `json:"next_key"`
+	CurrentFingerprint  string      `json:"current_fingerprint"`
+	NextFingerprint     string      `json:"next_fingerprint"`
+	RotationScheduledAt int64       `json:"rotation_scheduled_at"`
+}
+
+type JWKSHandler struct {
+	vaultClient *vaultpkg.Client
+	logger      *slog.Logger
+}
+
+func NewJWKSHandler(vaultClient *vaultpkg.Client, logger *slog.Logger) *JWKSHandler {
+	return &JWKSHandler{
+		vaultClient: vaultClient,
+		logger:      logger,
+	}
+}
+
+func (h *JWKSHandler) Handle(w http.ResponseWriter, r *http.Request) {
+	clientCfg := httputil.ClientConfigFromContext(r.Context())
+	if clientCfg == nil {
+		writeError(w, http.StatusInternalServerError, "internal_error", "Missing client config")
+		return
+	}
+
+	tenantID := clientCfg.TenantID
+	clientID := clientCfg.ClientID
+
+	currentKey, err := h.vaultClient.GetCurrentCAKey(r.Context(), tenantID, clientID)
+	if err != nil {
+		h.logger.Error("failed to get current CA key", "error", err)
+		writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve CA keys")
+		return
+	}
+
+	currentJWK, err := cert.PublicKeyToJWK(currentKey.PublicKey)
+	if err != nil {
+		h.logger.Error("failed to convert current key to JWK", "error", err)
+		writeError(w, http.StatusInternalServerError, "internal_error", "Failed to convert CA key to JWK")
+		return
+	}
+
+	currentFP, _ := cert.CAFingerprint(currentKey.PublicKey)
+
+	resp := JWKSResponse{
+		CurrentKey:         currentJWK,
+		NextKey:            "",
+		CurrentFingerprint: currentFP,
+		NextFingerprint:    "",
+	}
+
+	nextKey, err := h.vaultClient.GetNextCAKey(r.Context(), tenantID, clientID)
+	if err == nil && nextKey != nil {
+		nextJWK, err := cert.PublicKeyToJWK(nextKey.PublicKey)
+		if err == nil {
+			resp.NextKey = nextJWK
+			nextFP, _ := cert.CAFingerprint(nextKey.PublicKey)
+			resp.NextFingerprint = nextFP
+		}
+	}
+
+	meta, err := h.vaultClient.GetMetadata(r.Context(), tenantID, clientID)
+	if err == nil && meta != nil && meta.NextRotationAt != "" {
+		if t, err := time.Parse(time.RFC3339, meta.NextRotationAt); err == nil {
+			resp.RotationScheduledAt = t.Unix()
+		}
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusOK)
+	json.NewEncoder(w).Encode(resp)
+}
diff --git a/signer/internal/handler/revoke.go b/signer/internal/handler/revoke.go
new file mode 100644
index 000000000..b9b69f6e7
--- /dev/null
+++ b/signer/internal/handler/revoke.go
@@ -0,0 +1,110 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+	"encoding/json"
+	"log/slog"
+	"net/http"
+
+	"github.com/apache/airavata-custos/signer/internal/audit"
+	"github.com/apache/airavata-custos/signer/internal/httputil"
+	"github.com/apache/airavata-custos/signer/internal/metrics"
+)
+
+type RevokeRequest struct {
+	SerialNumber  *int64  `json:"serial_number,omitempty"`
+	KeyID         *string `json:"key_id,omitempty"`
+	CAFingerprint *string `json:"ca_fingerprint,omitempty"`
+	Reason        string  `json:"reason"`
+}
+
+type RevokeResponse struct {
+	Success      bool   `json:"success"`
+	Message      string `json:"message"`
+	RevokedCount int    `json:"revoked_count"`
+}
+
+type RevokeHandler struct {
+	auditLogger *audit.Logger
+	logger      *slog.Logger
+}
+
+func NewRevokeHandler(auditLogger *audit.Logger, logger *slog.Logger) *RevokeHandler {
+	return &RevokeHandler{
+		auditLogger: auditLogger,
+		logger:      logger,
+	}
+}
+
+func (h *RevokeHandler) Handle(w http.ResponseWriter, r *http.Request) {
+	clientCfg := httputil.ClientConfigFromContext(r.Context())
+	if clientCfg == nil {
+		writeError(w, http.StatusInternalServerError, "internal_error", "Missing client config")
+		return
+	}
+
+	tenantID := clientCfg.TenantID
+	clientID := clientCfg.ClientID
+
+	var req RevokeRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		metrics.RevokeRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
+		return
+	}
+
+	if req.SerialNumber == nil && req.KeyID == nil && req.CAFingerprint == nil {
+		metrics.RevokeRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		writeError(w, http.StatusBadRequest, "invalid_request", "At least one of serial_number, key_id, or ca_fingerprint is required")
+		return
+	}
+
+	if req.Reason == "" {
+		metrics.RevokeRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		writeError(w, http.StatusBadRequest, "invalid_request", "Missing required field: reason")
+		return
+	}
+
+	entry := &audit.RevocationEntry{
+		TenantID:      tenantID,
+		ClientID:      clientID,
+		SerialNumber:  req.SerialNumber,
+		KeyID:         req.KeyID,
+		CAFingerprint: req.CAFingerprint,
+		Reason:        req.Reason,
+		RevokedBy:     tenantID + ":" + clientID,
+	}
+
+	if err := h.auditLogger.LogRevocation(r.Context(), entry); err != nil {
+		metrics.RevokeRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		h.logger.Error("failed to record revocation", "error", err)
+		writeError(w, http.StatusInternalServerError, "internal_error", "Failed to record revocation")
+		return
+	}
+
+	metrics.RevokeRequestsTotal.WithLabelValues(tenantID, "success").Inc()
+
+	resp := RevokeResponse{
+		Success:      true,
+		Message:      "Certificate(s) revoked successfully",
+		RevokedCount: 1,
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusOK)
+	json.NewEncoder(w).Encode(resp)
+}
diff --git a/signer/internal/handler/revoke_test.go b/signer/internal/handler/revoke_test.go
new file mode 100644
index 000000000..d8d4e3c3d
--- /dev/null
+++ b/signer/internal/handler/revoke_test.go
@@ -0,0 +1,53 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+func TestRevokeRequest_NoIdentifier(t *testing.T) {
+	req := RevokeRequest{
+		Reason: "no reason",
+	}
+	if req.SerialNumber != nil || req.KeyID != nil || req.CAFingerprint != nil {
+		t.Error("all identifiers should be nil")
+	}
+}
+
+// Validate JSON marshaling
+func TestRevokeResponse_JSON(t *testing.T) {
+	resp := RevokeResponse{
+		Success:      true,
+		Message:      "Certificate(s) revoked successfully",
+		RevokedCount: 1,
+	}
+	data, err := json.Marshal(resp)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	var parsed map[string]interface{}
+	json.Unmarshal(data, &parsed)
+
+	if parsed["success"] != true {
+		t.Error("expected success true")
+	}
+	if parsed["revoked_count"].(float64) != 1 {
+		t.Error("expected revoked_count 1")
+	}
+}
diff --git a/signer/internal/handler/sign.go b/signer/internal/handler/sign.go
new file mode 100644
index 000000000..fed25f3ea
--- /dev/null
+++ b/signer/internal/handler/sign.go
@@ -0,0 +1,298 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/apache/airavata-custos/signer/internal/audit"
+	"github.com/apache/airavata-custos/signer/internal/auth"
+	"github.com/apache/airavata-custos/signer/internal/cert"
+	"github.com/apache/airavata-custos/signer/internal/httputil"
+	"github.com/apache/airavata-custos/signer/internal/metrics"
+	"github.com/apache/airavata-custos/signer/internal/policy"
+	"github.com/apache/airavata-custos/signer/internal/validation"
+	vaultpkg "github.com/apache/airavata-custos/signer/internal/vault"
+)
+
+var principalRegex = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
+
+type SignRequest struct {
+	Principal       string `json:"principal"`
+	TTLSeconds      int    `json:"ttl_seconds"`
+	PublicKey       string `json:"public_key"`
+	UserAccessToken string `json:"user_access_token"`
+}
+
+type SignResponse struct {
+	Certificate    string `json:"certificate"`
+	SerialNumber   int64  `json:"serial_number"`
+	ValidAfter     int64  `json:"valid_after"`
+	ValidBefore    int64  `json:"valid_before"`
+	CAFingerprint  string `json:"ca_fingerprint"`
+	TargetHost     string `json:"target_host"`
+	TargetPort     int    `json:"target_port"`
+	TargetUsername string `json:"target_username"`
+}
+
+type SignHandler struct {
+	oidcValidator      *auth.OIDCValidator
+	policyEnforcer     *policy.Enforcer
+	principalValidator validation.PrincipalValidator
+	vaultClient        *vaultpkg.Client
+	auditLogger        *audit.Logger
+	logger             *slog.Logger
+}
+
+func NewSignHandler(
+	oidcValidator *auth.OIDCValidator,
+	policyEnforcer *policy.Enforcer,
+	principalValidator validation.PrincipalValidator,
+	vaultClient *vaultpkg.Client,
+	auditLogger *audit.Logger,
+	logger *slog.Logger,
+) *SignHandler {
+	return &SignHandler{
+		oidcValidator:      oidcValidator,
+		policyEnforcer:     policyEnforcer,
+		principalValidator: principalValidator,
+		vaultClient:        vaultClient,
+		auditLogger:        auditLogger,
+		logger:             logger,
+	}
+}
+
+func (h *SignHandler) Handle(w http.ResponseWriter, r *http.Request) {
+	startTime := time.Now()
+	clientCfg := httputil.ClientConfigFromContext(r.Context())
+	if clientCfg == nil {
+		writeError(w, http.StatusInternalServerError, "internal_error", "Missing client config")
+		return
+	}
+
+	tenantID := clientCfg.TenantID
+	clientID := clientCfg.ClientID
+
+	defer func() {
+		duration := time.Since(startTime).Seconds()
+		metrics.SignDurationSeconds.WithLabelValues(tenantID).Observe(duration)
+	}()
+
+	var req SignRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
+		return
+	}
+
+	if req.Principal == "" {
+		metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		writeError(w, http.StatusBadRequest, "invalid_request", "Missing required field: principal")
+		return
+	}
+	if !principalRegex.MatchString(req.Principal) {
+		metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		writeError(w, http.StatusBadRequest, "invalid_request", "Invalid principal format: must match ^[a-z_][a-z0-9_-]{0,31}$")
+		return
+	}
+
+	if req.PublicKey == "" {
+		metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		writeError(w, http.StatusBadRequest, "invalid_request", "Missing required field: public_key")
+		return
+	}
+	if req.UserAccessToken == "" {
+		metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		writeError(w, http.StatusBadRequest, "invalid_request", "Missing required field: user_access_token")
+		return
+	}
+
+	sshPubKey, err := cert.ParseSSHPublicKey(req.PublicKey)
+	if err != nil {
+		metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		writeError(w, http.StatusBadRequest, "invalid_request", "Invalid SSH public key format")
+		return
+	}
+
+	identity, err := h.oidcValidator.ValidateAccessToken(r.Context(), req.UserAccessToken)
+	if err != nil {
+		metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		if authErr, ok := err.(*auth.AuthError); ok {
+			writeError(w, authErr.Status, authErr.Code, authErr.Message)
+			return
+		}
+		writeError(w, http.StatusInternalServerError, "internal_error", "Token validation error")
+		return
+	}
+
+	sourceIP := httputil.SourceIPFromContext(r.Context())
+	if err := h.policyEnforcer.Enforce(req.TTLSeconds, sshPubKey.Type(), sourceIP, clientCfg); err != nil {
+		metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		if policyErr, ok := err.(*policy.PolicyError); ok {
+			writeError(w, http.StatusBadRequest, "policy_violation", policyErr.Message)
+			return
+		}
+		writeError(w, http.StatusInternalServerError, "internal_error", "Policy enforcement error")
+		return
+	}
+
+	valResult, err := h.principalValidator.Validate(tenantID, clientID, req.Principal, identity.Subject)
+	if err != nil {
+		metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		if valErr, ok := err.(*validation.ValidationError); ok {
+			httputil.WriteJSONErrorWithExtra(w, http.StatusForbidden, "principal_denied", valErr.Message, map[string]string{
+				"reason_code": valErr.ReasonCode,
+			})
+			return
+		}
+		writeError(w, http.StatusInternalServerError, "internal_error", "Principal validation error")
+		return
+	}
+
+	validatedPrincipal := valResult.ValidatedPrincipal
+
+	caKeyPair, err := h.vaultClient.GetCurrentCAKey(r.Context(), tenantID, clientID)
+	if err != nil {
+		metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		metrics.VaultOperationsTotal.WithLabelValues("get_ca_key", "error").Inc()
+		h.logger.Error("failed to get CA key", "error", err, "tenant_id", tenantID, "client_id", clientID)
+		writeError(w, http.StatusServiceUnavailable, "internal_error", "CA key storage unavailable")
+		return
+	}
+	metrics.VaultOperationsTotal.WithLabelValues("get_ca_key", "success").Inc()
+
+	serial, err := h.vaultClient.IncrementSerialCounter(r.Context(), tenantID, clientID)
+	if err != nil {
+		metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		metrics.VaultOperationsTotal.WithLabelValues("increment_serial", "error").Inc()
+		h.logger.Error("failed to increment serial counter", "error", err, "tenant_id", tenantID, "client_id", clientID)
+		writeError(w, http.StatusInternalServerError, "internal_error", "Failed to allocate serial number")
+		return
+	}
+	metrics.VaultOperationsTotal.WithLabelValues("increment_serial", "success").Inc()
+
+	criticalOpts := policy.GetCriticalOptions(clientCfg)
+
+	signReq := &cert.SignRequest{
+		PublicKey:       sshPubKey,
+		CAPrivateKeyPEM: caKeyPair.PrivateKey,
+		Serial:          uint64(serial),
+		Principal:       validatedPrincipal,
+		ClientID:        clientID,
+		TTLSeconds:      uint64(req.TTLSeconds),
+		CriticalOptions: criticalOpts,
+	}
+
+	signResult, err := cert.SignCertificate(signReq)
+	if err != nil {
+		metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+		h.logger.Error("failed to sign certificate", "error", err)
+		writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sign certificate")
+		return
+	}
+
+	tokenHash := cert.HashToken(req.UserAccessToken)
+	pubKeyFP := cert.SSHPublicKeyFingerprint(sshPubKey)
+
+	auditEntry := &audit.IssuanceEntry{
+		TenantID:             tenantID,
+		ClientID:             clientID,
+		SerialNumber:         serial,
+		KeyID:                signResult.KeyID,
+		Principal:            validatedPrincipal,
+		UserEmail:            identity.Email,
+		PublicKeyFingerprint: pubKeyFP,
+		CAFingerprint:        signResult.CAFingerprint,
+		ValidAfter:           time.Unix(int64(signResult.ValidAfter), 0).UTC(),
+		ValidBefore:          time.Unix(int64(signResult.ValidBefore), 0).UTC(),
+		SourceIP:             sourceIP,
+		UserAccessTokenHash:  tokenHash,
+	}
+
+	if err := h.auditLogger.LogIssuance(r.Context(), auditEntry); err != nil {
+		if !isDuplicateKeyError(err) {
+			metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+			h.logger.Error("failed to write audit log", "error", err)
+			writeError(w, http.StatusInternalServerError, "internal_error", "Failed to record issuance")
+			return
+		}
+
+		// Duplicate serial — retry with a fresh serial
+		h.logger.Warn("duplicate serial detected, retrying with fresh serial",
+			"serial", serial, "tenant_id", tenantID, "client_id", clientID)
+
+		retrySerial, err := h.vaultClient.IncrementSerialCounter(r.Context(), tenantID, clientID)
+		if err != nil {
+			metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+			metrics.VaultOperationsTotal.WithLabelValues("increment_serial", "error").Inc()
+			writeError(w, http.StatusInternalServerError, "internal_error", "Failed to allocate serial number")
+			return
+		}
+		metrics.VaultOperationsTotal.WithLabelValues("increment_serial", "success").Inc()
+
+		signReq.Serial = uint64(retrySerial)
+		retryResult, err := cert.SignCertificate(signReq)
+		if err != nil {
+			metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+			h.logger.Error("failed to sign certificate on retry", "error", err)
+			writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sign certificate")
+			return
+		}
+
+		serial = retrySerial
+		signResult = retryResult
+		auditEntry.SerialNumber = retrySerial
+		auditEntry.KeyID = retryResult.KeyID
+		auditEntry.CAFingerprint = retryResult.CAFingerprint
+		auditEntry.ValidAfter = time.Unix(int64(retryResult.ValidAfter), 0).UTC()
+		auditEntry.ValidBefore = time.Unix(int64(retryResult.ValidBefore), 0).UTC()
+
+		if err := h.auditLogger.LogIssuance(r.Context(), auditEntry); err != nil {
+			metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+			h.logger.Error("failed to write audit log on retry", "error", err)
+			writeError(w, http.StatusInternalServerError, "internal_error", "Failed to record issuance")
+			return
+		}
+	}
+
+	metrics.SignRequestsTotal.WithLabelValues(tenantID, "success").Inc()
+
+	resp := SignResponse{
+		Certificate:    base64.StdEncoding.EncodeToString(signResult.CertBytes),
+		SerialNumber:   serial,
+		ValidAfter:     int64(signResult.ValidAfter),
+		ValidBefore:    int64(signResult.ValidBefore),
+		CAFingerprint:  signResult.CAFingerprint,
+		TargetHost:     clientCfg.TargetHost,
+		TargetPort:     clientCfg.TargetPort,
+		TargetUsername: validatedPrincipal,
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusOK)
+	json.NewEncoder(w).Encode(resp)
+}
+
+// isDuplicateKeyError detects MySQL error 1062 (duplicate key constraint).
+func isDuplicateKeyError(err error) bool {
+	return err != nil && strings.Contains(err.Error(), "Duplicate entry")
+}
diff --git a/signer/internal/handler/sign_test.go b/signer/internal/handler/sign_test.go
new file mode 100644
index 000000000..14b094262
--- /dev/null
+++ b/signer/internal/handler/sign_test.go
@@ -0,0 +1,85 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+	"errors"
+	"regexp"
+	"testing"
+)
+
+func TestPrincipalRegex(t *testing.T) {
+	re := regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
+
+	tests := []struct {
+		name  string
+		input string
+		valid bool
+	}{
+		// Valid cases
+		{"simple lowercase", "jdoe", true},
+		{"underscore start", "_user", true},
+		{"with hyphen", "j-doe", true},
+		{"with underscore", "j_doe", true},
+		{"with numbers", "user123", true},
+		{"single char", "a", true},
+		{"32 chars", "abcdefghijklmnopqrstuvwxyz012345", true},
+
+		// Invalid cases
+		{"uppercase", "JDoe", false},
+		{"dot", "j.doe", false},
+		{"at sign", "jdoe@example", false},
+		{"digit start", "1user", false},
+		{"33 chars", "abcdefghijklmnopqrstuvwxyz0123456", false},
+		{"empty", "", false},
+		{"space", "j doe", false},
+		{"special chars", "user$", false},
+		{"John.Doe", "John.Doe", false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := re.MatchString(tt.input)
+			if got != tt.valid {
+				t.Errorf("principalRegex.MatchString(%q) = %v, want %v", tt.input, got, tt.valid)
+			}
+		})
+	}
+}
+
+func TestIsDuplicateKeyError(t *testing.T) {
+	tests := []struct {
+		name string
+		err  error
+		want bool
+	}{
+		{"nil error", nil, false},
+		{"generic error", errors.New("connection refused"), false},
+		{"mysql duplicate entry", errors.New("Error 1062 (23000): Duplicate entry '42' for key 'serial_number'"), true},
+		{"wrapped duplicate entry", errors.New("failed to insert: Duplicate entry '99' for key 'PRIMARY'"), true},
+		{"partial match", errors.New("Duplicate entry"), true},
+		{"case sensitive no match", errors.New("duplicate entry"), false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := isDuplicateKeyError(tt.err)
+			if got != tt.want {
+				t.Errorf("isDuplicateKeyError(%v) = %v, want %v", tt.err, got, tt.want)
+			}
+		})
+	}
+}
diff --git a/signer/internal/handler/userinfo.go b/signer/internal/handler/userinfo.go
new file mode 100644
index 000000000..a5c1ca4a1
--- /dev/null
+++ b/signer/internal/handler/userinfo.go
@@ -0,0 +1,55 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/apache/airavata-custos/signer/internal/httputil"
+)
+
+type UserInfoResponse struct {
+	Subject   string `json:"subject"`
+	Issuer    string `json:"issuer"`
+	Email     string `json:"email"`
+	Principal string `json:"principal"`
+}
+
+type UserInfoHandler struct{}
+
+func NewUserInfoHandler() *UserInfoHandler {
+	return &UserInfoHandler{}
+}
+
+func (h *UserInfoHandler) Handle(w http.ResponseWriter, r *http.Request) {
+	identity := httputil.UserIdentityFromContext(r.Context())
+	if identity == nil {
+		writeError(w, http.StatusUnauthorized, "unauthorized", "Missing user identity")
+		return
+	}
+
+	resp := UserInfoResponse{
+		Subject:   identity.Subject,
+		Issuer:    identity.Issuer,
+		Email:     identity.Email,
+		Principal: identity.Principal,
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusOK)
+	json.NewEncoder(w).Encode(resp)
+}
diff --git a/signer/internal/handler/userinfo_test.go b/signer/internal/handler/userinfo_test.go
new file mode 100644
index 000000000..9d997d2c1
--- /dev/null
+++ b/signer/internal/handler/userinfo_test.go
@@ -0,0 +1,93 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/apache/airavata-custos/signer/internal/httputil"
+)
+
+func TestUserInfoHandler_MissingIdentity(t *testing.T) {
+	h := NewUserInfoHandler()
+
+	req := httptest.NewRequest("GET", "/api/v1/userinfo", nil)
+	rec := httptest.NewRecorder()
+	h.Handle(rec, req)
+
+	if rec.Code != http.StatusUnauthorized {
+		t.Errorf("expected 401, got %d", rec.Code)
+	}
+}
+
+func TestUserInfoHandler_Success(t *testing.T) {
+	h := NewUserInfoHandler()
+
+	req := httptest.NewRequest("GET", "/api/v1/userinfo", nil)
+	ctx := httputil.WithUserIdentity(req.Context(), &httputil.UserIdentityContext{
+		Issuer:    "https://cilogon.org",
+		Subject:   "http://cilogon.org/serverA/users/12345",
+		Principal: "jdoe",
+	})
+	req = req.WithContext(ctx)
+
+	rec := httptest.NewRecorder()
+	h.Handle(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Errorf("expected 200, got %d", rec.Code)
+	}
+
+	var resp UserInfoResponse
+	if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+		t.Fatalf("failed to decode response: %v", err)
+	}
+
+	if resp.Subject != "http://cilogon.org/serverA/users/12345" {
+		t.Errorf("subject: got %q", resp.Subject)
+	}
+	if resp.Issuer != "https://cilogon.org" {
+		t.Errorf("issuer: got %q", resp.Issuer)
+	}
+	if resp.Principal != "jdoe" {
+		t.Errorf("principal: got %q", resp.Principal)
+	}
+}
+
+func TestUserInfoResponse_JSONFormat(t *testing.T) {
+	resp := UserInfoResponse{
+		Subject:   "user123",
+		Issuer:    "https://example.com",
+		Principal: "jdoe",
+	}
+
+	data, err := json.Marshal(resp)
+	if err != nil {
+		t.Fatalf("marshal: %v", err)
+	}
+
+	var parsed map[string]interface{}
+	json.Unmarshal(data, &parsed)
+
+	for _, key := range []string{"subject", "issuer", "principal"} {
+		if _, ok := parsed[key]; !ok {
+			t.Errorf("missing key %q in JSON output", key)
+		}
+	}
+}
diff --git a/signer/internal/httputil/context.go b/signer/internal/httputil/context.go
new file mode 100644
index 000000000..77932fc91
--- /dev/null
+++ b/signer/internal/httputil/context.go
@@ -0,0 +1,95 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package httputil provides shared HTTP utilities like context keys and JSON helpers.
+package httputil
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+
+	"github.com/apache/airavata-custos/signer/internal/store"
+)
+
+type contextKey string
+
+const (
+	clientConfigKey contextKey = "clientConfig"
+	sourceIPKey     contextKey = "sourceIP"
+	userIdentityKey contextKey = "userIdentity"
+)
+
+type UserIdentityContext struct {
+	Issuer    string
+	Subject   string
+	Email     string
+	Principal string
+}
+
+func WithUserIdentity(ctx context.Context, id *UserIdentityContext) context.Context {
+	return context.WithValue(ctx, userIdentityKey, id)
+}
+
+func UserIdentityFromContext(ctx context.Context) *UserIdentityContext {
+	if v := ctx.Value(userIdentityKey); v != nil {
+		return v.(*UserIdentityContext)
+	}
+	return nil
+}
+
+func WithClientConfig(ctx context.Context, cfg *store.ClientConfig) context.Context {
+	return context.WithValue(ctx, clientConfigKey, cfg)
+}
+
+func ClientConfigFromContext(ctx context.Context) *store.ClientConfig {
+	if v := ctx.Value(clientConfigKey); v != nil {
+		return v.(*store.ClientConfig)
+	}
+	return nil
+}
+
+func WithSourceIP(ctx context.Context, ip string) context.Context {
+	return context.WithValue(ctx, sourceIPKey, ip)
+}
+
+func SourceIPFromContext(ctx context.Context) string {
+	if v := ctx.Value(sourceIPKey); v != nil {
+		return v.(string)
+	}
+	return ""
+}
+
+func WriteJSONError(w http.ResponseWriter, status int, code, message string) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	json.NewEncoder(w).Encode(map[string]string{
+		"error":   code,
+		"message": message,
+	})
+}
+
+func WriteJSONErrorWithExtra(w http.ResponseWriter, status int, code, message string, extra map[string]string) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	resp := map[string]string{
+		"error":   code,
+		"message": message,
+	}
+	for k, v := range extra {
+		resp[k] = v
+	}
+	json.NewEncoder(w).Encode(resp)
+}
diff --git a/signer/internal/httputil/context_test.go b/signer/internal/httputil/context_test.go
new file mode 100644
index 000000000..99928c3f8
--- /dev/null
+++ b/signer/internal/httputil/context_test.go
@@ -0,0 +1,92 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package httputil
+
+import (
+	"context"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/apache/airavata-custos/signer/internal/store"
+)
+
+func TestClientConfigContext(t *testing.T) {
+	cfg := &store.ClientConfig{
+		TenantID: "t1",
+		ClientID: "c1",
+	}
+	ctx := WithClientConfig(context.Background(), cfg)
+	got := ClientConfigFromContext(ctx)
+	if got == nil {
+		t.Fatal("expected non-nil config")
+	}
+	if got.TenantID != "t1" || got.ClientID != "c1" {
+		t.Errorf("unexpected config: %+v", got)
+	}
+}
+
+func TestClientConfigContext_Nil(t *testing.T) {
+	got := ClientConfigFromContext(context.Background())
+	if got != nil {
+		t.Error("expected nil for empty context")
+	}
+}
+
+func TestSourceIPContext(t *testing.T) {
+	ctx := WithSourceIP(context.Background(), "10.0.0.1")
+	got := SourceIPFromContext(ctx)
+	if got != "10.0.0.1" {
+		t.Errorf("expected 10.0.0.1, got %s", got)
+	}
+}
+
+func TestSourceIPContext_Empty(t *testing.T) {
+	got := SourceIPFromContext(context.Background())
+	if got != "" {
+		t.Errorf("expected empty, got %s", got)
+	}
+}
+
+func TestWriteJSONError(t *testing.T) {
+	rec := httptest.NewRecorder()
+	WriteJSONError(rec, 401, "unauthorized", "Missing header")
+
+	if rec.Code != 401 {
+		t.Errorf("expected 401, got %d", rec.Code)
+	}
+	if rec.Header().Get("Content-Type") != "application/json" {
+		t.Error("expected application/json content type")
+	}
+	body := rec.Body.String()
+	if body == "" {
+		t.Error("expected non-empty body")
+	}
+}
+
+func TestWriteJSONErrorWithExtra(t *testing.T) {
+	rec := httptest.NewRecorder()
+	WriteJSONErrorWithExtra(rec, 403, "principal_denied", "Not allowed", map[string]string{
+		"reason_code": "COMANAGE_NOT_IMPLEMENTED",
+	})
+
+	if rec.Code != 403 {
+		t.Errorf("expected 403, got %d", rec.Code)
+	}
+	body := rec.Body.String()
+	if body == "" {
+		t.Error("expected non-empty body")
+	}
+}
diff --git a/signer/internal/metrics/metrics.go b/signer/internal/metrics/metrics.go
new file mode 100644
index 000000000..61a4ac6ff
--- /dev/null
+++ b/signer/internal/metrics/metrics.go
@@ -0,0 +1,56 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package metrics defines Prometheus metric collectors.
+package metrics
+
+import (
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promauto"
+)
+
+var (
+	SignRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+		Name: "signer_sign_requests_total",
+		Help: "Total number of signing requests",
+	}, []string{"tenant_id", "status"})
+
+	SignDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
+		Name:    "signer_sign_duration_seconds",
+		Help:    "Duration of signing requests in seconds",
+		Buckets: prometheus.DefBuckets,
+	}, []string{"tenant_id"})
+
+	RevokeRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+		Name: "signer_revoke_requests_total",
+		Help: "Total number of revocation requests",
+	}, []string{"tenant_id", "status"})
+
+	AuthFailuresTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+		Name: "signer_auth_failures_total",
+		Help: "Total number of authentication failures",
+	}, []string{"reason"})
+
+	VaultOperationsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+		Name: "signer_vault_operations_total",
+		Help: "Total number of Vault operations",
+	}, []string{"operation", "status"})
+
+	DBQueryDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
+		Name:    "signer_db_query_duration_seconds",
+		Help:    "Duration of database queries in seconds",
+		Buckets: prometheus.DefBuckets,
+	}, []string{"query"})
+)
diff --git a/signer/internal/metrics/metrics_test.go b/signer/internal/metrics/metrics_test.go
new file mode 100644
index 000000000..c33b8b42b
--- /dev/null
+++ b/signer/internal/metrics/metrics_test.go
@@ -0,0 +1,62 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package metrics
+
+import (
+	"testing"
+
+	"github.com/prometheus/client_golang/prometheus"
+)
+
+func TestMetricsRegistered(t *testing.T) {
+	// Just verify the metrics exist and can be collected
+	SignRequestsTotal.WithLabelValues("test", "success").Inc()
+	SignDurationSeconds.WithLabelValues("test").Observe(0.1)
+	RevokeRequestsTotal.WithLabelValues("test", "success").Inc()
+	AuthFailuresTotal.WithLabelValues("unauthorized").Inc()
+	VaultOperationsTotal.WithLabelValues("get_ca_key", "success").Inc()
+	DBQueryDurationSeconds.WithLabelValues("select").Observe(0.01)
+
+	// Verify they can be gathered without panicking
+	gathering, err := prometheus.DefaultGatherer.Gather()
+	if err != nil {
+		t.Fatalf("failed to gather metrics: %v", err)
+	}
+	if len(gathering) == 0 {
+		t.Error("expected some metric families")
+	}
+
+	// Check that our custom metrics exist
+	found := make(map[string]bool)
+	for _, mf := range gathering {
+		found[mf.GetName()] = true
+	}
+
+	expectedMetrics := []string{
+		"signer_sign_requests_total",
+		"signer_sign_duration_seconds",
+		"signer_revoke_requests_total",
+		"signer_auth_failures_total",
+		"signer_vault_operations_total",
+		"signer_db_query_duration_seconds",
+	}
+
+	for _, name := range expectedMetrics {
+		if !found[name] {
+			t.Errorf("metric %s not found in gathered metrics", name)
+		}
+	}
+}
diff --git a/signer/internal/policy/enforcer.go b/signer/internal/policy/enforcer.go
new file mode 100644
index 000000000..b6d006793
--- /dev/null
+++ b/signer/internal/policy/enforcer.go
@@ -0,0 +1,97 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package policy
+
+import (
+	"fmt"
+	"net"
+	"strings"
+
+	"github.com/apache/airavata-custos/signer/internal/cert"
+	"github.com/apache/airavata-custos/signer/internal/store"
+)
+
+type Enforcer struct {
+	defaultMaxTTL       int
+	defaultAllowedTypes []string
+}
+
+func NewEnforcer(defaultMaxTTL int, defaultAllowedTypes []string) *Enforcer {
+	return &Enforcer{
+		defaultMaxTTL:       defaultMaxTTL,
+		defaultAllowedTypes: defaultAllowedTypes,
+	}
+}
+
+type PolicyError struct {
+	Message string
+}
+
+func (e *PolicyError) Error() string {
+	return e.Message
+}
+
+func (e *Enforcer) Enforce(ttlSeconds int, sshKeyType string, sourceIP string, clientCfg *store.ClientConfig) error {
+	maxTTL := clientCfg.MaxTTLSeconds
+	if maxTTL == 0 {
+		maxTTL = e.defaultMaxTTL
+	}
+	if ttlSeconds <= 0 {
+		return &PolicyError{Message: fmt.Sprintf("TTL must be greater than 0, got %d", ttlSeconds)}
+	}
+	if ttlSeconds > maxTTL {
+		return &PolicyError{Message: fmt.Sprintf("Requested TTL %d seconds exceeds maximum allowed TTL %d seconds", ttlSeconds, maxTTL)}
+	}
+
+	normalizedType := cert.NormalizeKeyType(sshKeyType)
+	allowedTypes := clientCfg.AllowedKeyTypes
+	if len(allowedTypes) == 0 {
+		allowedTypes = e.defaultAllowedTypes
+	}
+	typeAllowed := false
+	for _, at := range allowedTypes {
+		if strings.EqualFold(at, normalizedType) {
+			typeAllowed = true
+			break
+		}
+	}
+	if !typeAllowed {
+		return &PolicyError{Message: fmt.Sprintf("Key type %q is not allowed. Allowed types: %v", normalizedType, allowedTypes)}
+	}
+
+	if clientCfg.SourceAddressRestriction != nil && *clientCfg.SourceAddressRestriction != "" {
+		_, cidr, err := net.ParseCIDR(*clientCfg.SourceAddressRestriction)
+		if err != nil {
+			return &PolicyError{Message: fmt.Sprintf("Invalid source address restriction CIDR: %s", *clientCfg.SourceAddressRestriction)}
+		}
+		ip := net.ParseIP(sourceIP)
+		if ip == nil {
+			return &PolicyError{Message: fmt.Sprintf("Could not parse source IP: %s", sourceIP)}
+		}
+		if !cidr.Contains(ip) {
+			return &PolicyError{Message: fmt.Sprintf("Source IP %s is not within allowed CIDR %s", sourceIP, *clientCfg.SourceAddressRestriction)}
+		}
+	}
+
+	return nil
+}
+
+func GetCriticalOptions(clientCfg *store.ClientConfig) map[string]string {
+	if clientCfg.CriticalOptions != nil && len(clientCfg.CriticalOptions) > 0 {
+		return clientCfg.CriticalOptions
+	}
+	return nil
+}
diff --git a/signer/internal/policy/enforcer_test.go b/signer/internal/policy/enforcer_test.go
new file mode 100644
index 000000000..cc33de05a
--- /dev/null
+++ b/signer/internal/policy/enforcer_test.go
@@ -0,0 +1,167 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package policy
+
+import (
+	"testing"
+
+	"github.com/apache/airavata-custos/signer/internal/store"
+)
+
+func clientCfg(maxTTL int, keyTypes []string, sourceRestriction *string, critOpts map[string]string) *store.ClientConfig {
+	return &store.ClientConfig{
+		MaxTTLSeconds:            maxTTL,
+		AllowedKeyTypes:          keyTypes,
+		SourceAddressRestriction: sourceRestriction,
+		CriticalOptions:          critOpts,
+	}
+}
+
+func strPtr(s string) *string {
+	return &s
+}
+
+func TestEnforce_TTLWithinLimit(t *testing.T) {
+	e := NewEnforcer(86400, []string{"ed25519", "rsa", "ecdsa"})
+	cfg := clientCfg(86400, []string{"ed25519"}, nil, nil)
+	err := e.Enforce(3600, "ssh-ed25519", "10.0.0.1", cfg)
+	if err != nil {
+		t.Errorf("expected no error, got: %v", err)
+	}
+}
+
+func TestEnforce_TTLExceedsLimit(t *testing.T) {
+	e := NewEnforcer(86400, []string{"ed25519"})
+	cfg := clientCfg(3600, []string{"ed25519"}, nil, nil)
+	err := e.Enforce(7200, "ssh-ed25519", "10.0.0.1", cfg)
+	if err == nil {
+		t.Fatal("expected error for TTL exceeding limit")
+	}
+	pErr, ok := err.(*PolicyError)
+	if !ok {
+		t.Fatalf("expected PolicyError, got %T", err)
+	}
+	if pErr.Message != "Requested TTL 7200 seconds exceeds maximum allowed TTL 3600 seconds" {
+		t.Errorf("unexpected message: %s", pErr.Message)
+	}
+}
+
+func TestEnforce_TTLAtExactLimit(t *testing.T) {
+	e := NewEnforcer(86400, []string{"ed25519"})
+	cfg := clientCfg(3600, []string{"ed25519"}, nil, nil)
+	err := e.Enforce(3600, "ssh-ed25519", "10.0.0.1", cfg)
+	if err != nil {
+		t.Errorf("expected no error, got: %v", err)
+	}
+}
+
+func TestEnforce_NullMaxTTLUsesDefault(t *testing.T) {
+	e := NewEnforcer(86400, []string{"ed25519"})
+	cfg := clientCfg(0, []string{"ed25519"}, nil, nil) // 0 = null equivalent
+	err := e.Enforce(86400, "ssh-ed25519", "10.0.0.1", cfg)
+	if err != nil {
+		t.Errorf("expected no error, got: %v", err)
+	}
+}
+
+func TestEnforce_TTLZero(t *testing.T) {
+	e := NewEnforcer(86400, []string{"ed25519"})
+	cfg := clientCfg(86400, []string{"ed25519"}, nil, nil)
+	err := e.Enforce(0, "ssh-ed25519", "10.0.0.1", cfg)
+	if err == nil {
+		t.Fatal("expected error for TTL 0")
+	}
+}
+
+func TestEnforce_NegativeTTL(t *testing.T) {
+	e := NewEnforcer(86400, []string{"ed25519"})
+	cfg := clientCfg(86400, []string{"ed25519"}, nil, nil)
+	err := e.Enforce(-100, "ssh-ed25519", "10.0.0.1", cfg)
+	if err == nil {
+		t.Fatal("expected error for negative TTL")
+	}
+}
+
+func TestEnforce_DisallowedKeyType(t *testing.T) {
+	e := NewEnforcer(86400, []string{"ed25519"})
+	cfg := clientCfg(86400, []string{"ed25519"}, nil, nil)
+	err := e.Enforce(3600, "ssh-rsa", "10.0.0.1", cfg)
+	if err == nil {
+		t.Fatal("expected error for disallowed key type")
+	}
+	pErr := err.(*PolicyError)
+	if pErr == nil {
+		t.Fatal("expected PolicyError")
+	}
+}
+
+func TestEnforce_AllowedKeyType(t *testing.T) {
+	e := NewEnforcer(86400, []string{"ed25519", "rsa"})
+	cfg := clientCfg(86400, []string{"ed25519", "rsa"}, nil, nil)
+	err := e.Enforce(3600, "ssh-rsa", "10.0.0.1", cfg)
+	if err != nil {
+		t.Errorf("expected no error, got: %v", err)
+	}
+}
+
+func TestEnforce_SourceAddressRejected(t *testing.T) {
+	e := NewEnforcer(86400, []string{"ed25519"})
+	cidr := "10.0.0.0/8"
+	cfg := clientCfg(86400, []string{"ed25519"}, &cidr, nil)
+	err := e.Enforce(3600, "ssh-ed25519", "192.168.1.1", cfg)
+	if err == nil {
+		t.Fatal("expected error for source address outside CIDR")
+	}
+}
+
+func TestEnforce_SourceAddressAccepted(t *testing.T) {
+	e := NewEnforcer(86400, []string{"ed25519"})
+	cidr := "10.0.0.0/8"
+	cfg := clientCfg(86400, []string{"ed25519"}, &cidr, nil)
+	err := e.Enforce(3600, "ssh-ed25519", "10.1.2.3", cfg)
+	if err != nil {
+		t.Errorf("expected no error, got: %v", err)
+	}
+}
+
+func TestEnforce_NoSourceRestriction(t *testing.T) {
+	e := NewEnforcer(86400, []string{"ed25519"})
+	cfg := clientCfg(86400, []string{"ed25519"}, nil, nil)
+	err := e.Enforce(3600, "ssh-ed25519", "192.168.1.1", cfg)
+	if err != nil {
+		t.Errorf("expected no error, got: %v", err)
+	}
+}
+
+func TestGetCriticalOptions(t *testing.T) {
+	opts := map[string]string{"source-address": "10.0.0.0/8"}
+	cfg := clientCfg(86400, nil, nil, opts)
+	result := GetCriticalOptions(cfg)
+	if result == nil {
+		t.Fatal("expected non-nil critical options")
+	}
+	if result["source-address"] != "10.0.0.0/8" {
+		t.Errorf("expected source-address, got %v", result)
+	}
+}
+
+func TestGetCriticalOptions_Nil(t *testing.T) {
+	cfg := clientCfg(86400, nil, nil, nil)
+	result := GetCriticalOptions(cfg)
+	if result != nil {
+		t.Errorf("expected nil critical options, got %v", result)
+	}
+}
diff --git a/signer/internal/policy/key_type.go b/signer/internal/policy/key_type.go
new file mode 100644
index 000000000..9199e9a3b
--- /dev/null
+++ b/signer/internal/policy/key_type.go
@@ -0,0 +1,17 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package policy enforces signing policies (TTL, key types, source addresses, critical options).
+package policy
diff --git a/signer/internal/server/bearer_middleware_test.go b/signer/internal/server/bearer_middleware_test.go
new file mode 100644
index 000000000..e9b1fc455
--- /dev/null
+++ b/signer/internal/server/bearer_middleware_test.go
@@ -0,0 +1,125 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/lestrrat-go/jwx/v2/jwk"
+
+	"github.com/apache/airavata-custos/signer/internal/auth"
+	"github.com/apache/airavata-custos/signer/internal/config"
+	"github.com/apache/airavata-custos/signer/internal/httputil"
+)
+
+// newTestOIDCValidator creates an OIDCValidator with token validation disabled
+// so it returns a default identity without needing real JWKS.
+func newTestOIDCValidator(devMode bool) *auth.OIDCValidator {
+	v := auth.NewOIDCValidator(config.AuthConfig{}, config.DevModeConfig{
+		Enabled: devMode,
+	})
+	if !devMode {
+		// Set a custom JWKS fetcher that returns an empty key set to avoid HTTP calls
+		v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+			return jwk.NewSet(), nil
+		}
+	}
+	return v
+}
+
+func TestBearerAuthMiddleware_MissingHeader(t *testing.T) {
+	validator := newTestOIDCValidator(true)
+	handler := BearerAuthMiddleware(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusUnauthorized {
+		t.Errorf("expected 401, got %d", rec.Code)
+	}
+
+	var body map[string]string
+	json.NewDecoder(rec.Body).Decode(&body)
+	if body["error"] != "unauthorized" {
+		t.Errorf("expected error code 'unauthorized', got %q", body["error"])
+	}
+}
+
+func TestBearerAuthMiddleware_InvalidScheme(t *testing.T) {
+	validator := newTestOIDCValidator(true)
+	handler := BearerAuthMiddleware(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+	req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusUnauthorized {
+		t.Errorf("expected 401, got %d", rec.Code)
+	}
+}
+
+func TestBearerAuthMiddleware_EmptyToken(t *testing.T) {
+	validator := newTestOIDCValidator(true)
+	handler := BearerAuthMiddleware(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+	req.Header.Set("Authorization", "Bearer ")
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusUnauthorized {
+		t.Errorf("expected 401, got %d", rec.Code)
+	}
+}
+
+func TestBearerAuthMiddleware_DisabledValidation(t *testing.T) {
+	// When dev mode is enabled, any token is accepted with default identity
+	validator := newTestOIDCValidator(true)
+	var capturedIdentity *httputil.UserIdentityContext
+
+	handler := BearerAuthMiddleware(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		capturedIdentity = httputil.UserIdentityFromContext(r.Context())
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+	req.Header.Set("Authorization", "Bearer some-token")
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Errorf("expected 200, got %d", rec.Code)
+	}
+
+	if capturedIdentity == nil {
+		t.Fatal("expected user identity in context, got nil")
+	}
+	if capturedIdentity.Subject != "dev-user" {
+		t.Errorf("expected subject 'dev-user', got %q", capturedIdentity.Subject)
+	}
+}
diff --git a/signer/internal/server/cors.go b/signer/internal/server/cors.go
new file mode 100644
index 000000000..383512d0e
--- /dev/null
+++ b/signer/internal/server/cors.go
@@ -0,0 +1,50 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"net/http"
+
+	"github.com/apache/airavata-custos/signer/internal/config"
+)
+
+func CORSMiddleware(cfg config.CORSConfig) func(http.Handler) http.Handler {
+	allowedSet := make(map[string]bool, len(cfg.AllowedOrigins))
+	for _, o := range cfg.AllowedOrigins {
+		allowedSet[o] = true
+	}
+
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			origin := r.Header.Get("Origin")
+			if origin != "" && allowedSet[origin] {
+				w.Header().Set("Access-Control-Allow-Origin", origin)
+				w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+				w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Client-Id, X-Client-Secret")
+				w.Header().Set("Access-Control-Max-Age", "3600")
+				w.Header().Set("Vary", "Origin")
+			}
+
+			// Handle preflight
+			if r.Method == http.MethodOptions {
+				w.WriteHeader(http.StatusNoContent)
+				return
+			}
+
+			next.ServeHTTP(w, r)
+		})
+	}
+}
diff --git a/signer/internal/server/cors_test.go b/signer/internal/server/cors_test.go
new file mode 100644
index 000000000..731270079
--- /dev/null
+++ b/signer/internal/server/cors_test.go
@@ -0,0 +1,125 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/apache/airavata-custos/signer/internal/config"
+)
+
+func TestCORSMiddleware_AllowedOrigin(t *testing.T) {
+	cfg := config.CORSConfig{
+		Enabled:        true,
+		AllowedOrigins: []string{"http://localhost:5173", "https://custos.example.com"},
+	}
+
+	handler := CORSMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+	req.Header.Set("Origin", "http://localhost:5173")
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Errorf("expected 200, got %d", rec.Code)
+	}
+
+	acao := rec.Header().Get("Access-Control-Allow-Origin")
+	if acao != "http://localhost:5173" {
+		t.Errorf("expected ACAO 'http://localhost:5173', got %q", acao)
+	}
+}
+
+func TestCORSMiddleware_DisallowedOrigin(t *testing.T) {
+	cfg := config.CORSConfig{
+		Enabled:        true,
+		AllowedOrigins: []string{"http://localhost:5173"},
+	}
+
+	handler := CORSMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+	req.Header.Set("Origin", "https://evil.com")
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	acao := rec.Header().Get("Access-Control-Allow-Origin")
+	if acao != "" {
+		t.Errorf("expected no ACAO header for disallowed origin, got %q", acao)
+	}
+}
+
+func TestCORSMiddleware_PreflightOptions(t *testing.T) {
+	cfg := config.CORSConfig{
+		Enabled:        true,
+		AllowedOrigins: []string{"http://localhost:5173"},
+	}
+
+	nextCalled := false
+	handler := CORSMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		nextCalled = true
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("OPTIONS", "/api/v1/certificates", nil)
+	req.Header.Set("Origin", "http://localhost:5173")
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusNoContent {
+		t.Errorf("expected 204 for preflight, got %d", rec.Code)
+	}
+	if nextCalled {
+		t.Error("next handler should not be called for preflight")
+	}
+
+	acam := rec.Header().Get("Access-Control-Allow-Methods")
+	if acam == "" {
+		t.Error("expected Access-Control-Allow-Methods header")
+	}
+}
+
+func TestCORSMiddleware_NoOriginHeader(t *testing.T) {
+	cfg := config.CORSConfig{
+		Enabled:        true,
+		AllowedOrigins: []string{"http://localhost:5173"},
+	}
+
+	handler := CORSMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+	// No Origin header
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Errorf("expected 200, got %d", rec.Code)
+	}
+
+	acao := rec.Header().Get("Access-Control-Allow-Origin")
+	if acao != "" {
+		t.Errorf("expected no ACAO header without Origin, got %q", acao)
+	}
+}
diff --git a/signer/internal/server/middleware.go b/signer/internal/server/middleware.go
new file mode 100644
index 000000000..ccd70ad3e
--- /dev/null
+++ b/signer/internal/server/middleware.go
@@ -0,0 +1,150 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"net/http"
+	"strings"
+
+	"github.com/apache/airavata-custos/signer/internal/auth"
+	"github.com/apache/airavata-custos/signer/internal/httputil"
+	"github.com/apache/airavata-custos/signer/internal/metrics"
+)
+
+// BearerAuthMiddleware authenticates requests using an OIDC Bearer token in the
+// Authorization header. Used for user-facing endpoints (certificates, userinfo).
+func BearerAuthMiddleware(oidcValidator *auth.OIDCValidator) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			authHeader := r.Header.Get("Authorization")
+			if authHeader == "" {
+				metrics.AuthFailuresTotal.WithLabelValues("missing_bearer").Inc()
+				httputil.WriteJSONError(w, http.StatusUnauthorized, "unauthorized", "Missing Authorization header")
+				return
+			}
+
+			if !strings.HasPrefix(authHeader, "Bearer ") {
+				metrics.AuthFailuresTotal.WithLabelValues("invalid_bearer").Inc()
+				httputil.WriteJSONError(w, http.StatusUnauthorized, "unauthorized", "Authorization header must use Bearer scheme")
+				return
+			}
+
+			token := strings.TrimPrefix(authHeader, "Bearer ")
+			if token == "" {
+				metrics.AuthFailuresTotal.WithLabelValues("empty_bearer").Inc()
+				httputil.WriteJSONError(w, http.StatusUnauthorized, "unauthorized", "Empty Bearer token")
+				return
+			}
+
+			identity, err := oidcValidator.ValidateAccessToken(r.Context(), token)
+			if err != nil {
+				metrics.AuthFailuresTotal.WithLabelValues("invalid_token").Inc()
+				if authErr, ok := err.(*auth.AuthError); ok {
+					httputil.WriteJSONError(w, authErr.Status, authErr.Code, authErr.Message)
+					return
+				}
+				httputil.WriteJSONError(w, http.StatusUnauthorized, "unauthorized", "Token validation failed")
+				return
+			}
+
+			ctx := httputil.WithUserIdentity(r.Context(), &httputil.UserIdentityContext{
+				Issuer:    identity.Issuer,
+				Subject:   identity.Subject,
+				Email:     identity.Email,
+				Principal: identity.Principal,
+			})
+			next.ServeHTTP(w, r.WithContext(ctx))
+		})
+	}
+}
+
+// ClientAuthMiddleware authenticates requests using X-Client-Id and X-Client-Secret headers.
+func ClientAuthMiddleware(authenticator *auth.ClientAuthenticator) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			clientIDHeader := r.Header.Get("X-Client-Id")
+			secretHeader := r.Header.Get("X-Client-Secret")
+
+			result, err := authenticator.Authenticate(r.Context(), clientIDHeader, secretHeader)
+			if err != nil {
+				if authErr, ok := err.(*auth.AuthError); ok {
+					metrics.AuthFailuresTotal.WithLabelValues(authErr.Code).Inc()
+					httputil.WriteJSONError(w, authErr.Status, authErr.Code, authErr.Message)
+					return
+				}
+				httputil.WriteJSONError(w, http.StatusInternalServerError, "internal_error", "Authentication error")
+				return
+			}
+
+			ctx := httputil.WithClientConfig(r.Context(), result.Config)
+			next.ServeHTTP(w, r.WithContext(ctx))
+		})
+	}
+}
+
+// SourceIPMiddleware extracts the client source IP from X-Forwarded-For or remote address.
+func SourceIPMiddleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		ip := extractSourceIP(r)
+		ctx := httputil.WithSourceIP(r.Context(), ip)
+		next.ServeHTTP(w, r.WithContext(ctx))
+	})
+}
+
+func BodyLimitMiddleware(maxBytes int64) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			if r.ContentLength > maxBytes {
+				httputil.WriteJSONError(w, http.StatusRequestEntityTooLarge, "invalid_request", "Request body too large")
+				return
+			}
+			r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
+func SecurityHeadersMiddleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("X-Content-Type-Options", "nosniff")
+		w.Header().Set("X-Frame-Options", "DENY")
+		w.Header().Set("X-XSS-Protection", "0")
+		w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
+		next.ServeHTTP(w, r)
+	})
+}
+
+func extractSourceIP(r *http.Request) string {
+	xff := r.Header.Get("X-Forwarded-For")
+	if xff != "" {
+		parts := strings.SplitN(xff, ",", 2)
+		ip := strings.TrimSpace(parts[0])
+		if ip != "" {
+			return ip
+		}
+	}
+
+	addr := r.RemoteAddr
+	if idx := strings.LastIndex(addr, ":"); idx != -1 {
+		if addr[0] == '[' {
+			if bracketIdx := strings.Index(addr, "]"); bracketIdx != -1 {
+				return addr[1:bracketIdx]
+			}
+		}
+		return addr[:idx]
+	}
+	return addr
+}
diff --git a/signer/internal/server/middleware_test.go b/signer/internal/server/middleware_test.go
new file mode 100644
index 000000000..a8217881e
--- /dev/null
+++ b/signer/internal/server/middleware_test.go
@@ -0,0 +1,124 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/apache/airavata-custos/signer/internal/httputil"
+)
+
+func TestExtractSourceIP_XForwardedFor(t *testing.T) {
+	req := httptest.NewRequest("GET", "/", nil)
+	req.Header.Set("X-Forwarded-For", "10.1.2.3, 192.168.1.1")
+	ip := extractSourceIP(req)
+	if ip != "10.1.2.3" {
+		t.Errorf("expected 10.1.2.3, got %s", ip)
+	}
+}
+
+func TestExtractSourceIP_RemoteAddr(t *testing.T) {
+	req := httptest.NewRequest("GET", "/", nil)
+	req.RemoteAddr = "192.168.1.100:12345"
+	ip := extractSourceIP(req)
+	if ip != "192.168.1.100" {
+		t.Errorf("expected 192.168.1.100, got %s", ip)
+	}
+}
+
+func TestExtractSourceIP_IPv6(t *testing.T) {
+	req := httptest.NewRequest("GET", "/", nil)
+	req.RemoteAddr = "[::1]:12345"
+	ip := extractSourceIP(req)
+	if ip != "::1" {
+		t.Errorf("expected ::1, got %s", ip)
+	}
+}
+
+func TestBodyLimitMiddleware_Rejection(t *testing.T) {
+	handler := BodyLimitMiddleware(100)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	body := strings.Repeat("x", 200)
+	req := httptest.NewRequest("POST", "/", strings.NewReader(body))
+	req.Header.Set("Content-Length", "200")
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusRequestEntityTooLarge {
+		t.Errorf("expected 413, got %d", rec.Code)
+	}
+}
+
+func TestBodyLimitMiddleware_Accepted(t *testing.T) {
+	handler := BodyLimitMiddleware(1000)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	body := strings.Repeat("x", 50)
+	req := httptest.NewRequest("POST", "/", strings.NewReader(body))
+	req.Header.Set("Content-Length", "50")
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Errorf("expected 200, got %d", rec.Code)
+	}
+}
+
+func TestSecurityHeadersMiddleware(t *testing.T) {
+	handler := SecurityHeadersMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("GET", "/", nil)
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	checks := map[string]string{
+		"X-Content-Type-Options": "nosniff",
+		"X-Frame-Options":        "DENY",
+		"X-XSS-Protection":       "0",
+		"Referrer-Policy":        "strict-origin-when-cross-origin",
+	}
+	for header, want := range checks {
+		got := rec.Header().Get(header)
+		if got != want {
+			t.Errorf("%s: expected %q, got %q", header, want, got)
+		}
+	}
+}
+
+func TestSourceIPMiddleware(t *testing.T) {
+	var extractedIP string
+	handler := SourceIPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		extractedIP = httputil.SourceIPFromContext(r.Context())
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("GET", "/", nil)
+	req.Header.Set("X-Forwarded-For", "1.2.3.4")
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	if extractedIP != "1.2.3.4" {
+		t.Errorf("expected 1.2.3.4, got %s", extractedIP)
+	}
+}
diff --git a/signer/internal/server/routes.go b/signer/internal/server/routes.go
new file mode 100644
index 000000000..97fbe2a81
--- /dev/null
+++ b/signer/internal/server/routes.go
@@ -0,0 +1,84 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"net/http"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/prometheus/client_golang/prometheus/promhttp"
+
+	"github.com/apache/airavata-custos/signer/internal/auth"
+	"github.com/apache/airavata-custos/signer/internal/config"
+)
+
+type Handlers struct {
+	Sign              http.HandlerFunc
+	Revoke            http.HandlerFunc
+	JWKS              http.HandlerFunc
+	CAPublicKey       http.HandlerFunc
+	Health            http.HandlerFunc
+	Admin             http.HandlerFunc
+	Certificates      http.HandlerFunc
+	CertificateDetail http.HandlerFunc
+	UserInfo          http.HandlerFunc
+}
+
+func NewRouter(
+	cfg *config.Config,
+	authenticator *auth.ClientAuthenticator,
+	oidcValidator *auth.OIDCValidator,
+	handlers Handlers,
+) *chi.Mux {
+	r := chi.NewRouter()
+
+	if cfg.CORS.Enabled {
+		r.Use(CORSMiddleware(cfg.CORS))
+	}
+	r.Use(SecurityHeadersMiddleware)
+	r.Use(SourceIPMiddleware)
+	r.Use(BodyLimitMiddleware(1 << 20)) // 1 MB
+
+	// Health endpoint (no auth)
+	r.Get("/api/v1/health", handlers.Health)
+
+	// Metrics endpoint (no auth)
+	if cfg.Metrics.Enabled {
+		r.Handle(cfg.Metrics.Path, promhttp.Handler())
+	}
+
+	// Client-authenticated routes (machine-to-machine)
+	r.Group(func(r chi.Router) {
+		r.Use(ClientAuthMiddleware(authenticator))
+
+		r.Post("/api/v1/sign", handlers.Sign)
+		r.Post("/api/v1/revoke", handlers.Revoke)
+		r.Get("/api/v1/jwks", handlers.JWKS)
+		r.Get("/api/v1/ca-public-key", handlers.CAPublicKey)
+		r.Post("/api/v1/admin/rotate-ca", handlers.Admin)
+	})
+
+	// User-authenticated routes (OIDC Bearer token)
+	r.Group(func(r chi.Router) {
+		r.Use(BearerAuthMiddleware(oidcValidator))
+
+		r.Get("/api/v1/certificates", handlers.Certificates)
+		r.Get("/api/v1/certificates/{serial}", handlers.CertificateDetail)
+		r.Get("/api/v1/userinfo", handlers.UserInfo)
+	})
+
+	return r
+}
diff --git a/signer/internal/server/server.go b/signer/internal/server/server.go
new file mode 100644
index 000000000..45a684c62
--- /dev/null
+++ b/signer/internal/server/server.go
@@ -0,0 +1,82 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/apache/airavata-custos/signer/internal/config"
+)
+
+// Server wraps the HTTP server with graceful shutdown.
+type Server struct {
+	httpServer      *http.Server
+	logger          *slog.Logger
+	shutdownTimeout time.Duration
+}
+
+func New(cfg config.ServerConfig, handler http.Handler, logger *slog.Logger) *Server {
+	return &Server{
+		httpServer: &http.Server{
+			Addr:         fmt.Sprintf(":%d", cfg.Port),
+			Handler:      handler,
+			ReadTimeout:  time.Duration(cfg.ReadTimeoutSeconds) * time.Second,
+			WriteTimeout: time.Duration(cfg.WriteTimeoutSeconds) * time.Second,
+		},
+		logger:          logger,
+		shutdownTimeout: time.Duration(cfg.ShutdownTimeoutSeconds) * time.Second,
+	}
+}
+
+// ListenAndServe starts the HTTP server and blocks until shutdown signal is received.
+func (s *Server) ListenAndServe() error {
+	errCh := make(chan error, 1)
+	go func() {
+		s.logger.Info("starting HTTP server", "addr", s.httpServer.Addr)
+		if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+			errCh <- err
+		}
+		close(errCh)
+	}()
+
+	quit := make(chan os.Signal, 1)
+	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+
+	select {
+	case sig := <-quit:
+		s.logger.Info("received shutdown signal", "signal", sig.String())
+	case err := <-errCh:
+		return fmt.Errorf("server error: %w", err)
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), s.shutdownTimeout)
+	defer cancel()
+
+	s.logger.Info("shutting down server", "timeout", s.shutdownTimeout.String())
+	if err := s.httpServer.Shutdown(ctx); err != nil {
+		return fmt.Errorf("server shutdown: %w", err)
+	}
+
+	s.logger.Info("server stopped")
+	return nil
+}
diff --git a/signer/internal/store/certificate_query.go b/signer/internal/store/certificate_query.go
new file mode 100644
index 000000000..3891920bf
--- /dev/null
+++ b/signer/internal/store/certificate_query.go
@@ -0,0 +1,148 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package store
+
+import (
+	"context"
+	"fmt"
+	"time"
+)
+
+type CertificateWithStatus struct {
+	ID                   int64
+	TenantID             string
+	ClientID             string
+	SerialNumber         int64
+	KeyID                string
+	Principal            string
+	UserEmail            string
+	PublicKeyFingerprint string
+	CAFingerprint        string
+	ValidAfter           time.Time
+	ValidBefore          time.Time
+	IssuedAt             time.Time
+	SourceIP             string
+	Revoked              bool
+	RevokedAt            *time.Time
+	RevocationReason     string
+}
+
+type CertificateListResult struct {
+	Certificates []CertificateWithStatus
+	Total        int
+}
+
+// ListCertificatesByEmail returns certificates issued to a user email, ordered by
+// issued_at descending (newest first). Includes revocation status via LEFT JOIN.
+func (d *DB) ListCertificatesByEmail(ctx context.Context, email string, limit, offset int) (*CertificateListResult, error) {
+	if limit <= 0 {
+		limit = 20
+	}
+	if limit > 100 {
+		limit = 100
+	}
+	if offset < 0 {
+		offset = 0
+	}
+
+	// Count total matching certificates
+	var total int
+	err := d.QueryRowContext(ctx,
+		`SELECT COUNT(*) FROM certificate_issuance_logs WHERE user_email = ?`,
+		email,
+	).Scan(&total)
+	if err != nil {
+		return nil, fmt.Errorf("counting certificates: %w", err)
+	}
+
+	// Fetch page with revocation status
+	rows, err := d.QueryContext(ctx,
+		`SELECT
+			c.id, c.tenant_id, c.client_id, c.serial_number, c.key_id,
+			c.principal, COALESCE(c.user_email, ''), c.public_key_fingerprint, c.ca_fingerprint,
+			c.valid_after, c.valid_before, c.issued_at, COALESCE(c.source_ip, ''),
+			CASE WHEN r.id IS NOT NULL THEN TRUE ELSE FALSE END AS revoked,
+			r.revoked_at,
+			COALESCE(r.reason, '') AS revocation_reason
+		 FROM certificate_issuance_logs c
+		 LEFT JOIN revocation_events r ON r.serial_number = c.serial_number
+		 WHERE c.user_email = ?
+		 ORDER BY c.issued_at DESC
+		 LIMIT ? OFFSET ?`,
+		email, limit, offset,
+	)
+	if err != nil {
+		return nil, fmt.Errorf("querying certificates: %w", err)
+	}
+	defer rows.Close()
+
+	var certs []CertificateWithStatus
+	for rows.Next() {
+		var cert CertificateWithStatus
+		var revokedAt *time.Time
+
+		if err := rows.Scan(
+			&cert.ID, &cert.TenantID, &cert.ClientID, &cert.SerialNumber, &cert.KeyID,
+			&cert.Principal, &cert.UserEmail, &cert.PublicKeyFingerprint, &cert.CAFingerprint,
+			&cert.ValidAfter, &cert.ValidBefore, &cert.IssuedAt, &cert.SourceIP,
+			&cert.Revoked, &revokedAt, &cert.RevocationReason,
+		); err != nil {
+			return nil, fmt.Errorf("scanning certificate row: %w", err)
+		}
+
+		cert.RevokedAt = revokedAt
+		certs = append(certs, cert)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, fmt.Errorf("iterating certificate rows: %w", err)
+	}
+
+	return &CertificateListResult{
+		Certificates: certs,
+		Total:        total,
+	}, nil
+}
+
+func (d *DB) GetCertificateBySerial(ctx context.Context, serial int64) (*CertificateWithStatus, error) {
+	var cert CertificateWithStatus
+	var revokedAt *time.Time
+
+	err := d.QueryRowContext(ctx,
+		`SELECT
+			c.id, c.tenant_id, c.client_id, c.serial_number, c.key_id,
+			c.principal, COALESCE(c.user_email, ''), c.public_key_fingerprint, c.ca_fingerprint,
+			c.valid_after, c.valid_before, c.issued_at, COALESCE(c.source_ip, ''),
+			CASE WHEN r.id IS NOT NULL THEN TRUE ELSE FALSE END AS revoked,
+			r.revoked_at,
+			COALESCE(r.reason, '') AS revocation_reason
+		 FROM certificate_issuance_logs c
+		 LEFT JOIN revocation_events r ON r.serial_number = c.serial_number
+		 WHERE c.serial_number = ?`,
+		serial,
+	).Scan(
+		&cert.ID, &cert.TenantID, &cert.ClientID, &cert.SerialNumber, &cert.KeyID,
+		&cert.Principal, &cert.UserEmail, &cert.PublicKeyFingerprint, &cert.CAFingerprint,
+		&cert.ValidAfter, &cert.ValidBefore, &cert.IssuedAt, &cert.SourceIP,
+		&cert.Revoked, &revokedAt, &cert.RevocationReason,
+	)
+	if err != nil {
+		return nil, fmt.Errorf("querying certificate: %w", err)
+	}
+
+	cert.RevokedAt = revokedAt
+	return &cert, nil
+}
diff --git a/signer/internal/store/client_config.go b/signer/internal/store/client_config.go
new file mode 100644
index 000000000..7c2d95c48
--- /dev/null
+++ b/signer/internal/store/client_config.go
@@ -0,0 +1,88 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package store
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"fmt"
+)
+
+type ClientConfig struct {
+	TenantID                 string
+	ClientID                 string
+	ClientSecret             string // BCrypt hash
+	TargetHost               string
+	TargetPort               int
+	MaxTTLSeconds            int
+	AllowedKeyTypes          []string
+	SourceAddressRestriction *string
+	CriticalOptions          map[string]string
+	Extensions               map[string]string
+	Enabled                  bool
+}
+
+func (d *DB) GetClientConfig(ctx context.Context, tenantID, clientID string) (*ClientConfig, error) {
+	var (
+		cc                       ClientConfig
+		allowedKeyTypesJSON      []byte
+		sourceAddressRestriction sql.NullString
+		criticalOptionsJSON      sql.NullString
+		extensionsJSON           sql.NullString
+	)
+
+	err := d.QueryRowContext(ctx,
+		`SELECT tenant_id, client_id, client_secret, target_host, target_port,
+		        max_ttl_seconds, allowed_key_types, source_address_restriction,
+		        critical_options, extensions, enabled
+		 FROM client_ssh_configs
+		 WHERE tenant_id = ? AND client_id = ?`,
+		tenantID, clientID,
+	).Scan(
+		&cc.TenantID, &cc.ClientID, &cc.ClientSecret, &cc.TargetHost, &cc.TargetPort,
+		&cc.MaxTTLSeconds, &allowedKeyTypesJSON, &sourceAddressRestriction,
+		&criticalOptionsJSON, &extensionsJSON, &cc.Enabled,
+	)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			return nil, nil
+		}
+		return nil, fmt.Errorf("querying client config: %w", err)
+	}
+
+	if err := json.Unmarshal(allowedKeyTypesJSON, &cc.AllowedKeyTypes); err != nil {
+		return nil, fmt.Errorf("unmarshaling allowed_key_types: %w", err)
+	}
+
+	if sourceAddressRestriction.Valid {
+		cc.SourceAddressRestriction = &sourceAddressRestriction.String
+	}
+
+	if criticalOptionsJSON.Valid && criticalOptionsJSON.String != "" {
+		if err := json.Unmarshal([]byte(criticalOptionsJSON.String), &cc.CriticalOptions); err != nil {
+			return nil, fmt.Errorf("unmarshaling critical_options: %w", err)
+		}
+	}
+
+	if extensionsJSON.Valid && extensionsJSON.String != "" {
+		if err := json.Unmarshal([]byte(extensionsJSON.String), &cc.Extensions); err != nil {
+			return nil, fmt.Errorf("unmarshaling extensions: %w", err)
+		}
+	}
+
+	return &cc, nil
+}
diff --git a/signer/internal/store/db.go b/signer/internal/store/db.go
new file mode 100644
index 000000000..ec3b656b9
--- /dev/null
+++ b/signer/internal/store/db.go
@@ -0,0 +1,57 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package store provides database access for client configs, issuance logs, and revocation events.
+package store
+
+import (
+	"database/sql"
+	"fmt"
+	"time"
+
+	_ "github.com/go-sql-driver/mysql"
+
+	"github.com/apache/airavata-custos/signer/internal/config"
+)
+
+type DB struct {
+	*sql.DB
+}
+
+func NewDB(cfg config.DatabaseConfig) (*DB, error) {
+	dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4",
+		cfg.Username, cfg.Password, cfg.Host, cfg.Port, cfg.Name)
+
+	db, err := sql.Open("mysql", dsn)
+	if err != nil {
+		return nil, fmt.Errorf("opening database: %w", err)
+	}
+
+	db.SetMaxOpenConns(cfg.MaxOpenConns)
+	db.SetMaxIdleConns(cfg.MaxIdleConns)
+	db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSeconds) * time.Second)
+
+	if err := db.Ping(); err != nil {
+		db.Close()
+		return nil, fmt.Errorf("pinging database: %w", err)
+	}
+
+	return &DB{db}, nil
+}
+
+func (d *DB) Ping() error {
+	var n int
+	return d.QueryRow("SELECT 1").Scan(&n)
+}
diff --git a/signer/internal/store/issuance_log.go b/signer/internal/store/issuance_log.go
new file mode 100644
index 000000000..823656eca
--- /dev/null
+++ b/signer/internal/store/issuance_log.go
@@ -0,0 +1,65 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package store
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"time"
+)
+
+type IssuanceLog struct {
+	TenantID             string
+	ClientID             string
+	SerialNumber         int64
+	KeyID                string
+	Principal            string
+	UserEmail            string
+	PublicKeyFingerprint string
+	CAFingerprint        string
+	ValidAfter           time.Time
+	ValidBefore          time.Time
+	SourceIP             string
+	UserAccessTokenHash  string
+	RequestMetadata      map[string]interface{}
+}
+
+// InsertIssuanceLog writes a certificate issuance entry. Fails on duplicate serial_number.
+func (d *DB) InsertIssuanceLog(ctx context.Context, log *IssuanceLog) error {
+	var metadataJSON []byte
+	if log.RequestMetadata != nil {
+		var err error
+		metadataJSON, err = json.Marshal(log.RequestMetadata)
+		if err != nil {
+			return fmt.Errorf("marshaling request_metadata: %w", err)
+		}
+	}
+
+	_, err := d.ExecContext(ctx,
+		`INSERT INTO certificate_issuance_logs
+		 (tenant_id, client_id, serial_number, key_id, principal, user_email, public_key_fingerprint,
+		  ca_fingerprint, valid_after, valid_before, source_ip, user_access_token_hash, request_metadata)
+		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+		log.TenantID, log.ClientID, log.SerialNumber, log.KeyID, log.Principal,
+		log.UserEmail, log.PublicKeyFingerprint, log.CAFingerprint, log.ValidAfter, log.ValidBefore,
+		log.SourceIP, log.UserAccessTokenHash, metadataJSON,
+	)
+	if err != nil {
+		return fmt.Errorf("inserting issuance log: %w", err)
+	}
+	return nil
+}
diff --git a/signer/internal/store/revocation.go b/signer/internal/store/revocation.go
new file mode 100644
index 000000000..f625e3547
--- /dev/null
+++ b/signer/internal/store/revocation.go
@@ -0,0 +1,58 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package store
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+)
+
+type RevocationEvent struct {
+	TenantID      string
+	ClientID      string
+	SerialNumber  *int64
+	KeyID         *string
+	CAFingerprint *string
+	Reason        string
+	RevokedBy     string
+}
+
+func (d *DB) InsertRevocationEvent(ctx context.Context, ev *RevocationEvent) error {
+	var serialNumber sql.NullInt64
+	if ev.SerialNumber != nil {
+		serialNumber = sql.NullInt64{Int64: *ev.SerialNumber, Valid: true}
+	}
+	var keyID sql.NullString
+	if ev.KeyID != nil {
+		keyID = sql.NullString{String: *ev.KeyID, Valid: true}
+	}
+	var caFingerprint sql.NullString
+	if ev.CAFingerprint != nil {
+		caFingerprint = sql.NullString{String: *ev.CAFingerprint, Valid: true}
+	}
+
+	_, err := d.ExecContext(ctx,
+		`INSERT INTO revocation_events
+		 (tenant_id, client_id, serial_number, key_id, ca_fingerprint, reason, revoked_by)
+		 VALUES (?, ?, ?, ?, ?, ?, ?)`,
+		ev.TenantID, ev.ClientID, serialNumber, keyID, caFingerprint, ev.Reason, ev.RevokedBy,
+	)
+	if err != nil {
+		return fmt.Errorf("inserting revocation event: %w", err)
+	}
+	return nil
+}
diff --git a/signer/internal/validation/comanage.go b/signer/internal/validation/comanage.go
new file mode 100644
index 000000000..b4ddaac50
--- /dev/null
+++ b/signer/internal/validation/comanage.go
@@ -0,0 +1,30 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package validation
+
+// COmanageValidator is a stub — full COmanage Registry integration is deferred.
+type COmanageValidator struct{}
+
+func NewCOmanageValidator() *COmanageValidator {
+	return &COmanageValidator{}
+}
+
+func (v *COmanageValidator) Validate(tenantID, clientID, principal, identitySubject string) (*ValidationResult, error) {
+	return nil, &ValidationError{
+		Message:    "Requested principal is not allowed",
+		ReasonCode: "COMANAGE_NOT_IMPLEMENTED",
+	}
+}
diff --git a/signer/internal/validation/noop.go b/signer/internal/validation/noop.go
new file mode 100644
index 000000000..251570624
--- /dev/null
+++ b/signer/internal/validation/noop.go
@@ -0,0 +1,30 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package validation
+
+// NoOpValidator — for development/testing use only.
+type NoOpValidator struct{}
+
+func NewNoOpValidator() *NoOpValidator {
+	return &NoOpValidator{}
+}
+
+func (v *NoOpValidator) Validate(tenantID, clientID, principal, identitySubject string) (*ValidationResult, error) {
+	return &ValidationResult{
+		Allowed:            true,
+		ValidatedPrincipal: principal,
+	}, nil
+}
diff --git a/signer/internal/validation/validator.go b/signer/internal/validation/validator.go
new file mode 100644
index 000000000..d60ba3a21
--- /dev/null
+++ b/signer/internal/validation/validator.go
@@ -0,0 +1,38 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package validation provides a pluggable PrincipalValidator interface.
+package validation
+
+type PrincipalValidator interface {
+	// Validate checks if the requested principal is authorized. The returned
+	// principal may differ from the requested one.
+	Validate(tenantID, clientID, principal, identitySubject string) (*ValidationResult, error)
+}
+
+type ValidationResult struct {
+	Allowed            bool
+	ValidatedPrincipal string
+	ReasonCode         string
+}
+
+type ValidationError struct {
+	Message    string
+	ReasonCode string
+}
+
+func (e *ValidationError) Error() string {
+	return e.Message
+}
diff --git a/signer/internal/validation/validator_test.go b/signer/internal/validation/validator_test.go
new file mode 100644
index 000000000..3eaaff6c9
--- /dev/null
+++ b/signer/internal/validation/validator_test.go
@@ -0,0 +1,65 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package validation
+
+import (
+	"testing"
+)
+
+func TestNoOpValidator_AllowsAll(t *testing.T) {
+	v := NewNoOpValidator()
+	result, err := v.Validate("t1", "c1", "anyuser", "sub123")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if !result.Allowed {
+		t.Error("expected allowed")
+	}
+	if result.ValidatedPrincipal != "anyuser" {
+		t.Errorf("expected anyuser, got %s", result.ValidatedPrincipal)
+	}
+}
+
+func TestCOmanageValidator_AlwaysDenies(t *testing.T) {
+	v := NewCOmanageValidator()
+	result, err := v.Validate("t1", "c1", "user", "sub123")
+	if result != nil {
+		t.Error("expected nil result")
+	}
+	if err == nil {
+		t.Fatal("expected error")
+	}
+	valErr, ok := err.(*ValidationError)
+	if !ok {
+		t.Fatalf("expected ValidationError, got %T", err)
+	}
+	if valErr.ReasonCode != "COMANAGE_NOT_IMPLEMENTED" {
+		t.Errorf("expected COMANAGE_NOT_IMPLEMENTED, got %s", valErr.ReasonCode)
+	}
+	if valErr.Message != "Requested principal is not allowed" {
+		t.Errorf("expected 'Requested principal is not allowed', got %s", valErr.Message)
+	}
+}
+
+func TestValidationError_Interface(t *testing.T) {
+	err := &ValidationError{
+		Message:    "Requested principal is not allowed",
+		ReasonCode: "NOT_ALLOWED",
+	}
+	if err.Error() != "Requested principal is not allowed" {
+		t.Errorf("unexpected error message: %s", err.Error())
+	}
+}
diff --git a/signer/internal/vault/client.go b/signer/internal/vault/client.go
new file mode 100644
index 000000000..856b5b48c
--- /dev/null
+++ b/signer/internal/vault/client.go
@@ -0,0 +1,390 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package vault provides Vault KV v2 operations for CA key storage and serial counters.
+package vault
+
+import (
+	"context"
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/json"
+	"encoding/pem"
+	"fmt"
+	"strconv"
+	"time"
+
+	vaultapi "github.com/hashicorp/vault/api"
+
+	"github.com/apache/airavata-custos/signer/internal/config"
+)
+
+type CAKeyPair struct {
+	PrivateKey []byte // PEM-encoded private key
+	PublicKey  []byte // PEM-encoded public key
+	Algorithm  string // e.g., "ed25519"
+	CreatedAt  string
+}
+
+type CAMetadata struct {
+	SerialCounter   int64
+	LastRotationAt  string
+	NextRotationAt  string
+	RotationPeriodH int
+	OverlapHours    int
+}
+
+type Client struct {
+	client      *vaultapi.Client
+	mountPath   string
+	rotationCfg config.RotationConfig
+}
+
+func NewClient(cfg config.VaultConfig, rotationCfg config.RotationConfig) (*Client, error) {
+	vaultCfg := vaultapi.DefaultConfig()
+	vaultCfg.Address = cfg.Address
+	vaultCfg.Timeout = time.Duration(cfg.TimeoutSeconds) * time.Second
+
+	client, err := vaultapi.NewClient(vaultCfg)
+	if err != nil {
+		return nil, fmt.Errorf("creating vault client: %w", err)
+	}
+	client.SetToken(cfg.Token)
+
+	return &Client{
+		client:      client,
+		mountPath:   cfg.MountPath,
+		rotationCfg: rotationCfg,
+	}, nil
+}
+
+func (c *Client) Healthy(ctx context.Context) error {
+	_, err := c.client.Sys().HealthWithContext(ctx)
+	if err != nil {
+		return fmt.Errorf("vault health check: %w", err)
+	}
+	return nil
+}
+
+func (c *Client) kvPath(parts ...string) string {
+	path := c.mountPath + "/data"
+	for _, p := range parts {
+		path += "/" + p
+	}
+	return path
+}
+
+// GetCurrentCAKey returns the current CA key, auto-creating one if it doesn't exist.
+func (c *Client) GetCurrentCAKey(ctx context.Context, tenantID, clientID string) (*CAKeyPair, error) {
+	kp, err := c.readCAKey(ctx, tenantID, clientID, "current")
+	if err != nil {
+		return nil, err
+	}
+	if kp != nil {
+		return kp, nil
+	}
+
+	kp, err = generateEd25519CAKey()
+	if err != nil {
+		return nil, err
+	}
+
+	if err := c.writeCAKey(ctx, tenantID, clientID, "current", kp); err != nil {
+		return nil, err
+	}
+
+	if err := c.initMetadata(ctx, tenantID, clientID); err != nil {
+		return nil, err
+	}
+
+	return kp, nil
+}
+
+// GetNextCAKey returns the next CA key, or nil if none exists.
+func (c *Client) GetNextCAKey(ctx context.Context, tenantID, clientID string) (*CAKeyPair, error) {
+	return c.readCAKey(ctx, tenantID, clientID, "next")
+}
+
+// IncrementSerialCounter atomically reads, increments, and writes the serial
+// counter using Vault KV v2 CAS (Check-and-Set) to prevent lost updates.
+func (c *Client) IncrementSerialCounter(ctx context.Context, tenantID, clientID string) (int64, error) {
+	meta, version, err := c.readMetadataWithVersion(ctx, tenantID, clientID)
+	if err != nil {
+		return 0, err
+	}
+	if meta == nil {
+		if err := c.initMetadata(ctx, tenantID, clientID); err != nil {
+			return 0, err
+		}
+		return 1, nil
+	}
+
+	newSerial := meta.SerialCounter + 1
+	meta.SerialCounter = newSerial
+	if err := c.writeMetadataCAS(ctx, tenantID, clientID, meta, version); err != nil {
+		return 0, err
+	}
+	return newSerial, nil
+}
+
+// RotateCA promotes next to current and generates a new next key.
+func (c *Client) RotateCA(ctx context.Context, tenantID, clientID string) (currentFP, nextFP string, err error) {
+	nextKey, err := c.readCAKey(ctx, tenantID, clientID, "next")
+	if err != nil {
+		return "", "", err
+	}
+
+	if nextKey != nil {
+		// Promote next to current
+		if err := c.writeCAKey(ctx, tenantID, clientID, "current", nextKey); err != nil {
+			return "", "", err
+		}
+	} else {
+		// Generate fresh current
+		newCurrent, err := generateEd25519CAKey()
+		if err != nil {
+			return "", "", err
+		}
+		if err := c.writeCAKey(ctx, tenantID, clientID, "current", newCurrent); err != nil {
+			return "", "", err
+		}
+	}
+
+	// Generate new next key
+	newNext, err := generateEd25519CAKey()
+	if err != nil {
+		return "", "", err
+	}
+	if err := c.writeCAKey(ctx, tenantID, clientID, "next", newNext); err != nil {
+		return "", "", err
+	}
+
+	now := time.Now().UTC()
+	meta, err := c.readMetadata(ctx, tenantID, clientID)
+	if err != nil {
+		return "", "", err
+	}
+	if meta == nil {
+		meta = &CAMetadata{SerialCounter: 0}
+	}
+	meta.LastRotationAt = now.Format(time.RFC3339)
+	meta.NextRotationAt = now.Add(time.Duration(c.rotationCfg.PeriodHours) * time.Hour).Format(time.RFC3339)
+	meta.RotationPeriodH = c.rotationCfg.PeriodHours
+	meta.OverlapHours = c.rotationCfg.OverlapHours
+	if err := c.writeMetadata(ctx, tenantID, clientID, meta); err != nil {
+		return "", "", err
+	}
+
+	// Read back keys to get fingerprints
+	currentKey, err := c.readCAKey(ctx, tenantID, clientID, "current")
+	if err != nil {
+		return "", "", err
+	}
+
+	return string(currentKey.PublicKey), string(newNext.PublicKey), nil
+}
+
+func (c *Client) GetMetadata(ctx context.Context, tenantID, clientID string) (*CAMetadata, error) {
+	return c.readMetadata(ctx, tenantID, clientID)
+}
+
+func (c *Client) readCAKey(ctx context.Context, tenantID, clientID, slot string) (*CAKeyPair, error) {
+	path := c.kvPath(tenantID, clientID, slot)
+	secret, err := c.client.Logical().ReadWithContext(ctx, path)
+	if err != nil {
+		return nil, fmt.Errorf("reading vault path %s: %w", path, err)
+	}
+	if secret == nil || secret.Data == nil {
+		return nil, nil
+	}
+
+	data, ok := secret.Data["data"].(map[string]interface{})
+	if !ok || data == nil {
+		return nil, nil
+	}
+
+	kp := &CAKeyPair{}
+	if v, ok := data["private_key"].(string); ok {
+		kp.PrivateKey = []byte(v)
+	}
+	if v, ok := data["public_key"].(string); ok {
+		kp.PublicKey = []byte(v)
+	}
+	if v, ok := data["algorithm"].(string); ok {
+		kp.Algorithm = v
+	}
+	if v, ok := data["created_at"].(string); ok {
+		kp.CreatedAt = v
+	}
+
+	if len(kp.PrivateKey) == 0 {
+		return nil, nil
+	}
+	return kp, nil
+}
+
+func (c *Client) writeCAKey(ctx context.Context, tenantID, clientID, slot string, kp *CAKeyPair) error {
+	path := c.kvPath(tenantID, clientID, slot)
+	data := map[string]interface{}{
+		"data": map[string]interface{}{
+			"private_key": string(kp.PrivateKey),
+			"public_key":  string(kp.PublicKey),
+			"algorithm":   kp.Algorithm,
+			"created_at":  kp.CreatedAt,
+		},
+	}
+	_, err := c.client.Logical().WriteWithContext(ctx, path, data)
+	if err != nil {
+		return fmt.Errorf("writing vault path %s: %w", path, err)
+	}
+	return nil
+}
+
+func (c *Client) readMetadata(ctx context.Context, tenantID, clientID string) (*CAMetadata, error) {
+	meta, _, err := c.readMetadataWithVersion(ctx, tenantID, clientID)
+	return meta, err
+}
+
+func (c *Client) readMetadataWithVersion(ctx context.Context, tenantID, clientID string) (*CAMetadata, int, error) {
+	path := c.kvPath(tenantID, clientID, "metadata")
+	secret, err := c.client.Logical().ReadWithContext(ctx, path)
+	if err != nil {
+		return nil, 0, fmt.Errorf("reading vault metadata: %w", err)
+	}
+	if secret == nil || secret.Data == nil {
+		return nil, 0, nil
+	}
+
+	// Extract KV v2 version from metadata.
+	// The Vault Go client decodes numbers as json.Number, not float64.
+	version := 0
+	if md, ok := secret.Data["metadata"].(map[string]interface{}); ok {
+		if v, ok := md["version"].(json.Number); ok {
+			if n, err := v.Int64(); err == nil {
+				version = int(n)
+			}
+		}
+	}
+
+	data, ok := secret.Data["data"].(map[string]interface{})
+	if !ok || data == nil {
+		return nil, 0, nil
+	}
+
+	meta := &CAMetadata{}
+	if v, ok := data["serial_counter"].(string); ok {
+		meta.SerialCounter, _ = strconv.ParseInt(v, 10, 64)
+	}
+	if v, ok := data["serial_counter"].(float64); ok {
+		meta.SerialCounter = int64(v)
+	}
+	if v, ok := data["last_rotation_at"].(string); ok {
+		meta.LastRotationAt = v
+	}
+	if v, ok := data["next_rotation_at"].(string); ok {
+		meta.NextRotationAt = v
+	}
+	if v, ok := data["rotation_period_hours"].(string); ok {
+		meta.RotationPeriodH, _ = strconv.Atoi(v)
+	}
+	if v, ok := data["rotation_period_hours"].(float64); ok {
+		meta.RotationPeriodH = int(v)
+	}
+	if v, ok := data["overlap_hours"].(string); ok {
+		meta.OverlapHours, _ = strconv.Atoi(v)
+	}
+	if v, ok := data["overlap_hours"].(float64); ok {
+		meta.OverlapHours = int(v)
+	}
+	return meta, version, nil
+}
+
+func (c *Client) writeMetadata(ctx context.Context, tenantID, clientID string, meta *CAMetadata) error {
+	path := c.kvPath(tenantID, clientID, "metadata")
+	data := map[string]interface{}{
+		"data": map[string]interface{}{
+			"serial_counter":        strconv.FormatInt(meta.SerialCounter, 10),
+			"last_rotation_at":      meta.LastRotationAt,
+			"next_rotation_at":      meta.NextRotationAt,
+			"rotation_period_hours": strconv.Itoa(meta.RotationPeriodH),
+			"overlap_hours":         strconv.Itoa(meta.OverlapHours),
+		},
+	}
+	_, err := c.client.Logical().WriteWithContext(ctx, path, data)
+	if err != nil {
+		return fmt.Errorf("writing vault metadata: %w", err)
+	}
+	return nil
+}
+
+func (c *Client) writeMetadataCAS(ctx context.Context, tenantID, clientID string, meta *CAMetadata, cas int) error {
+	path := c.kvPath(tenantID, clientID, "metadata")
+	data := map[string]interface{}{
+		"options": map[string]interface{}{
+			"cas": cas,
+		},
+		"data": map[string]interface{}{
+			"serial_counter":        strconv.FormatInt(meta.SerialCounter, 10),
+			"last_rotation_at":      meta.LastRotationAt,
+			"next_rotation_at":      meta.NextRotationAt,
+			"rotation_period_hours": strconv.Itoa(meta.RotationPeriodH),
+			"overlap_hours":         strconv.Itoa(meta.OverlapHours),
+		},
+	}
+	_, err := c.client.Logical().WriteWithContext(ctx, path, data)
+	if err != nil {
+		return fmt.Errorf("writing vault metadata (CAS=%d): %w", cas, err)
+	}
+	return nil
+}
+
+func (c *Client) initMetadata(ctx context.Context, tenantID, clientID string) error {
+	now := time.Now().UTC()
+	meta := &CAMetadata{
+		SerialCounter:   0,
+		LastRotationAt:  now.Format(time.RFC3339),
+		NextRotationAt:  now.Add(time.Duration(c.rotationCfg.PeriodHours) * time.Hour).Format(time.RFC3339),
+		RotationPeriodH: c.rotationCfg.PeriodHours,
+		OverlapHours:    c.rotationCfg.OverlapHours,
+	}
+	return c.writeMetadata(ctx, tenantID, clientID, meta)
+}
+
+func generateEd25519CAKey() (*CAKeyPair, error) {
+	pub, priv, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		return nil, fmt.Errorf("generating ed25519 key: %w", err)
+	}
+
+	privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
+	if err != nil {
+		return nil, fmt.Errorf("marshaling private key: %w", err)
+	}
+	privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+
+	pubBytes, err := x509.MarshalPKIXPublicKey(pub)
+	if err != nil {
+		return nil, fmt.Errorf("marshaling public key: %w", err)
+	}
+	pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+	return &CAKeyPair{
+		PrivateKey: privPEM,
+		PublicKey:  pubPEM,
+		Algorithm:  "ed25519",
+		CreatedAt:  time.Now().UTC().Format(time.RFC3339),
+	}, nil
+}
diff --git a/signer/main.go b/signer/main.go
new file mode 100644
index 000000000..f5f5c13a6
--- /dev/null
+++ b/signer/main.go
@@ -0,0 +1,218 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+	"errors"
+	"fmt"
+	"log/slog"
+	"os"
+	"strings"
+
+	"github.com/apache/airavata-custos/signer/internal/audit"
+	"github.com/apache/airavata-custos/signer/internal/auth"
+	"github.com/apache/airavata-custos/signer/internal/config"
+	"github.com/apache/airavata-custos/signer/internal/handler"
+	"github.com/apache/airavata-custos/signer/internal/policy"
+	"github.com/apache/airavata-custos/signer/internal/server"
+	"github.com/apache/airavata-custos/signer/internal/store"
+	"github.com/apache/airavata-custos/signer/internal/validation"
+	"github.com/apache/airavata-custos/signer/internal/vault"
+	"github.com/golang-migrate/migrate/v4"
+	_ "github.com/golang-migrate/migrate/v4/database/mysql"
+	_ "github.com/golang-migrate/migrate/v4/source/file"
+)
+
+const usage = `Custos Signer Service - SSH certificate signing service.
+
+Usage:
+  custos-signer <command> [options]
+
+Commands:
+  serve      Start the HTTP server (default)
+  migrate    Apply pending database migrations and exit
+
+Options:
+  --config string    Path to configuration file (default "config.yaml")
+  --auto-migrate     Automatically apply migrations on startup (serve only)
+`
+
+func main() {
+	cmd := "serve"
+	args := os.Args[1:]
+	if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
+		cmd = args[0]
+		args = args[1:]
+	}
+
+	configPath := "config.yaml"
+	autoMigrate := false
+	for i := 0; i < len(args); i++ {
+		switch args[i] {
+		case "--config":
+			if i+1 < len(args) {
+				configPath = args[i+1]
+				i++
+			}
+		case "--auto-migrate":
+			autoMigrate = true
+		case "--help", "-h":
+			fmt.Print(usage)
+			os.Exit(0)
+		}
+	}
+
+	cfg, err := config.Load(configPath)
+	if err != nil {
+		slog.Error("failed to load configuration", "error", err)
+		os.Exit(1)
+	}
+
+	logger := setupLogger(cfg.Logging)
+	slog.SetDefault(logger)
+
+	if cfg.DevMode.Enabled {
+		logger.Warn("DEV MODE ENABLED - OIDC token validation is disabled. DO NOT use in production.",
+			"default_email", cfg.DevMode.DefaultEmail)
+	}
+
+	switch cmd {
+	case "migrate":
+		runMigrate(cfg, logger)
+		logger.Info("migrations complete")
+		os.Exit(0)
+	case "serve":
+		runServer(cfg, logger, autoMigrate)
+	default:
+		fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmd)
+		fmt.Print(usage)
+		os.Exit(1)
+	}
+}
+
+// Applies pending migrations and exits.
+// Invoked by the "migrate" subcommand and also reused by --auto-migrate in serve mode.
+func runMigrate(cfg *config.Config, logger *slog.Logger) {
+	dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s",
+		cfg.Database.Username, cfg.Database.Password,
+		cfg.Database.Host, cfg.Database.Port, cfg.Database.Name)
+	m, err := migrate.New("file://migrations", "mysql://"+dsn)
+	if err != nil {
+		logger.Error("failed to create migrate instance", "error", err)
+		os.Exit(1)
+	}
+	if err := m.Up(); err != nil {
+		if errors.Is(err, migrate.ErrNoChange) {
+			logger.Info("database migrations: no change")
+		} else {
+			logger.Error("failed to run database migrations", "error", err)
+			os.Exit(1)
+		}
+	} else {
+		logger.Info("database migrations applied successfully")
+	}
+}
+
+func runServer(cfg *config.Config, logger *slog.Logger, autoMigrate bool) {
+	db, err := store.NewDB(cfg.Database)
+	if err != nil {
+		logger.Error("failed to connect to database", "error", err)
+		os.Exit(1)
+	}
+	defer db.Close()
+	logger.Info("connected to database")
+
+	if autoMigrate {
+		logger.Info("auto-migrate enabled, applying pending migrations")
+		runMigrate(cfg, logger)
+	}
+
+	vaultClient, err := vault.NewClient(cfg.Vault, cfg.Signer.CA.Rotation)
+	if err != nil {
+		logger.Error("failed to create vault client", "error", err)
+		os.Exit(1)
+	}
+	logger.Info("vault client initialized")
+
+	authenticator := auth.NewClientAuthenticator(db)
+	oidcValidator := auth.NewOIDCValidator(cfg.Signer.Auth, cfg.DevMode)
+	policyEnforcer := policy.NewEnforcer(cfg.Signer.Policy.Defaults.MaxTTLSeconds, cfg.Signer.Policy.Defaults.AllowedKeyTypes)
+	auditLogger := audit.NewLogger(db, logger)
+
+	var principalValidator validation.PrincipalValidator
+	switch strings.ToLower(cfg.Signer.Validation.PrincipalValidator) {
+	case "comanage":
+		principalValidator = validation.NewCOmanageValidator()
+		logger.Info("using COmanage principal validator (stub)")
+	default:
+		principalValidator = validation.NewNoOpValidator()
+		logger.Info("using NoOp principal validator")
+	}
+
+	signHandler := handler.NewSignHandler(oidcValidator, policyEnforcer, principalValidator, vaultClient, auditLogger, logger)
+	revokeHandler := handler.NewRevokeHandler(auditLogger, logger)
+	jwksHandler := handler.NewJWKSHandler(vaultClient, logger)
+	caPublicKeyHandler := handler.NewCAPublicKeyHandler(vaultClient, logger)
+	healthHandler := handler.NewHealthHandler(db, vaultClient)
+	adminHandler := handler.NewAdminHandler(vaultClient, logger)
+	certificatesHandler := handler.NewCertificatesHandler(db, logger)
+	userInfoHandler := handler.NewUserInfoHandler()
+
+	handlers := server.Handlers{
+		Sign:              signHandler.Handle,
+		Revoke:            revokeHandler.Handle,
+		JWKS:              jwksHandler.Handle,
+		CAPublicKey:       caPublicKeyHandler.Handle,
+		Health:            healthHandler.Handle,
+		Admin:             adminHandler.Handle,
+		Certificates:      certificatesHandler.HandleList,
+		CertificateDetail: certificatesHandler.HandleGet,
+		UserInfo:          userInfoHandler.Handle,
+	}
+
+	router := server.NewRouter(cfg, authenticator, oidcValidator, handlers)
+
+	srv := server.New(cfg.Server, router, logger)
+	if err := srv.ListenAndServe(); err != nil {
+		logger.Error("server error", "error", err)
+		os.Exit(1)
+	}
+}
+
+func setupLogger(cfg config.LoggingConfig) *slog.Logger {
+	var level slog.Level
+	switch strings.ToLower(cfg.Level) {
+	case "debug":
+		level = slog.LevelDebug
+	case "warn":
+		level = slog.LevelWarn
+	case "error":
+		level = slog.LevelError
+	default:
+		level = slog.LevelInfo
+	}
+
+	opts := &slog.HandlerOptions{Level: level}
+
+	var h slog.Handler
+	if strings.ToLower(cfg.Format) == "text" {
+		h = slog.NewTextHandler(os.Stdout, opts)
+	} else {
+		h = slog.NewJSONHandler(os.Stdout, opts)
+	}
+
+	return slog.New(h)
+}
diff --git a/signer/migrations/001_initial_schema.up.sql b/signer/migrations/001_initial_schema.up.sql
new file mode 100644
index 000000000..1fd2f2211
--- /dev/null
+++ b/signer/migrations/001_initial_schema.up.sql
@@ -0,0 +1,79 @@
+SET NAMES utf8mb4;
+SET time_zone = '+00:00';
+
+CREATE TABLE IF NOT EXISTS client_ssh_configs
+(
+    tenant_id                  VARCHAR(255) NOT NULL,
+    client_id                  VARCHAR(255) NOT NULL,
+    client_secret              VARCHAR(512) NOT NULL,
+    target_host                VARCHAR(255) NOT NULL,
+    target_port                INT          NOT NULL DEFAULT 22,
+    max_ttl_seconds            INT          NOT NULL DEFAULT 86400,
+    allowed_key_types          JSON         NOT NULL,
+    source_address_restriction VARCHAR(255) NULL,
+    critical_options           JSON         NULL,
+    extensions                 JSON         NULL,
+    enabled                    BOOLEAN      NOT NULL DEFAULT TRUE,
+    created_at                 TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+    updated_at                 TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
+                                                     ON UPDATE CURRENT_TIMESTAMP(6),
+
+    PRIMARY KEY (tenant_id, client_id),
+    INDEX idx_client_configs_enabled (enabled),
+    INDEX idx_client_configs_target_host (target_host)
+) ENGINE = InnoDB
+  DEFAULT CHARSET = utf8mb4
+  COLLATE = utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS certificate_issuance_logs
+(
+    id                     BIGINT       NOT NULL AUTO_INCREMENT,
+    tenant_id              VARCHAR(255) NOT NULL,
+    client_id              VARCHAR(255) NOT NULL,
+    serial_number          BIGINT       NOT NULL,
+    key_id                 VARCHAR(255) NOT NULL,
+    principal              VARCHAR(255) NOT NULL,
+    user_email             VARCHAR(255) NULL,
+    public_key_fingerprint VARCHAR(255) NOT NULL,
+    ca_fingerprint         VARCHAR(255) NOT NULL,
+    valid_after            TIMESTAMP(6) NOT NULL,
+    valid_before           TIMESTAMP(6) NOT NULL,
+    issued_at              TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+    source_ip              VARCHAR(45)  NULL,
+    user_access_token_hash VARCHAR(255) NULL,
+    request_metadata       JSON         NULL,
+
+    PRIMARY KEY (id),
+    UNIQUE KEY uq_issuance_serial (serial_number),
+    INDEX idx_issuance_tenant_client (tenant_id, client_id),
+    INDEX idx_issuance_keyid (key_id),
+    INDEX idx_issuance_principal (principal),
+    INDEX idx_issuance_user_email (user_email),
+    INDEX idx_issuance_ca_fingerprint (ca_fingerprint),
+    INDEX idx_issuance_issued_at (issued_at),
+    INDEX idx_issuance_valid_range (valid_after, valid_before)
+) ENGINE = InnoDB
+  DEFAULT CHARSET = utf8mb4
+  COLLATE = utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS revocation_events
+(
+    id             BIGINT       NOT NULL AUTO_INCREMENT,
+    tenant_id      VARCHAR(255) NOT NULL,
+    client_id      VARCHAR(255) NOT NULL,
+    serial_number  BIGINT       NULL,
+    key_id         VARCHAR(255) NULL,
+    ca_fingerprint VARCHAR(255) NULL,
+    revoked_at     TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+    reason         VARCHAR(255) NOT NULL,
+    revoked_by     VARCHAR(255) NOT NULL,
+
+    PRIMARY KEY (id),
+    INDEX idx_revocation_tenant_client (tenant_id, client_id),
+    INDEX idx_revocation_serial (serial_number),
+    INDEX idx_revocation_keyid (key_id),
+    INDEX idx_revocation_ca_fingerprint (ca_fingerprint),
+    INDEX idx_revocation_revoked_at (revoked_at)
+) ENGINE = InnoDB
+  DEFAULT CHARSET = utf8mb4
+  COLLATE = utf8mb4_unicode_ci;