Hardening for production

Externalised issuer and CORS, health probes, and a persistent security audit trail.

  • Part 12
  • advanced
  • about 60 minutes

You will build

configurable issuer and CORS, liveness and readiness probes, and an audited, ADMIN-readable event log

You will understand

why the issuer is part of your security identity, the difference between liveness and readiness, and what must never be written to an audit log

  • one migration, one audit service

Protocol correctness is not enough. A deployable authorization server also needs predictable runtime configuration, narrow cross-origin behavior, operational probes, and an audit trail.

Externalize the issuer

Instead of hardcoding:

http://localhost:9000

use:

authorization-server:
  issuer: ${AUTHORIZATION_SERVER_ISSUER:http://localhost:9000}

and inject it into AuthorizationServerSettings.

A production issuer should be the stable external HTTPS URL clients see, for example:

https://auth.example.com

The issuer participates in discovery and token validation. It is part of your security identity, not a cosmetic base URL.

Forwarded headers

For reverse-proxy deployments:

server:
  forward-headers-strategy: framework

The proxy itself must also be configured correctly and trusted appropriately. Do not treat arbitrary client-supplied forwarded headers as authoritative at the network boundary.

Explicit CORS

Configuration:

authorization-server:
  cors:
    allowed-origins: ${AUTHORIZATION_SERVER_CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173}

Build a CorsConfigurationSource using exact configured origins.

Allowed methods might include:

GET POST PUT DELETE OPTIONS

Allowed headers:

Authorization Content-Type Accept

Do not use * casually on an authorization server.

For the stateless bearer-token APIs in this template:

configuration.setAllowCredentials(false);

Then enable CORS in each relevant security filter chain.

Test preflight requests

Allowed origin:

curl -i \
  -X OPTIONS \
  http://localhost:9000/api/v1/clients \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: Authorization"

Verify:

Access-Control-Allow-Origin: http://localhost:5173

Unknown origin should not receive a matching allow-origin response.

Add Actuator with minimal exposure

Add the Actuator starter, then:

management:
  endpoints:
    web:
      exposure:
        include: health
  endpoint:
    health:
      probes:
        enabled: true
      show-details: never

Expose publicly only:

/actuator/health
/actuator/health/liveness
/actuator/health/readiness

Do not automatically expose sensitive diagnostic endpoints such as /env or /configprops to the public internet.

Health semantics

flowchart LR
    LB[Load balancer / orchestrator]
    L["/liveness"]
    R["/readiness"]
    App[Authorization Server]

    LB --> L --> App
    LB --> R --> App

Liveness answers roughly: should the process be restarted?

Readiness answers: should traffic currently be sent to this instance?

Those questions are operationally different.

Persistent security audit events

Create the table in V12__create_security_audit_events.sql:

CREATE TABLE security_audit_events (
    id UUID NOT NULL PRIMARY KEY,
    event_type VARCHAR(100) NOT NULL,
    actor VARCHAR(200),
    target_type VARCHAR(100),
    target_id VARCHAR(200),
    outcome VARCHAR(20) NOT NULL,
    ip_address VARCHAR(64),
    user_agent VARCHAR(500),
    details TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Indexes on created_at, actor, and event_type make common operational queries cheaper.

What should be audited?

Examples:

LOGIN_SUCCESS
LOGIN_FAILURE
PASSWORD_CHANGED
PASSWORD_RESET_BY_ADMIN
OAUTH_CLIENT_CREATED
OAUTH_CLIENT_UPDATED
OAUTH_CLIENT_SECRET_ROTATED
OAUTH_CLIENT_DELETED

What must not be audited?

Never place these in audit details:

raw password
password hash
access token
refresh token
authorization code
raw client secret
private signing key

An audit table often has a long retention period and broad operational visibility. Treat it as sensitive security data, not a dumping ground for request objects.

Capture actor and request context

A small audit service can obtain the authenticated actor from SecurityContextHolder and, when an HTTP request is available, record:

remote IP
user-agent

Be careful interpreting remoteAddr behind proxies; production IP attribution depends on a correctly configured trusted proxy chain.

ADMIN audit API

Add:

GET /api/v1/audit-events

under the existing ADMIN bearer security chain.

Return a pageable safe response that excludes fields you do not need to expose through the API.

Audit login events

Our existing authentication event listener is a natural place to record:

LOGIN_FAILURE
LOGIN_SUCCESS

Again, record the login name and outcome—not the password.

Audit management operations

After successful client mutation:

OAUTH_CLIENT_CREATED
OAUTH_CLIENT_UPDATED
OAUTH_CLIENT_SECRET_ROTATED
OAUTH_CLIENT_DELETED

Notice the word after. An audit event labelled SUCCESS should represent a mutation that actually completed.

Operational tests

Automate:

health endpoint is public              → 200
unauthenticated audit endpoint         → 401
ROLE_USER audit endpoint               → 403
ROLE_ADMIN audit endpoint              → 200
configured CORS origin                 → allowed
unknown CORS origin                    → not allowed

Figure: production-facing surfaces

flowchart TB
    Internet[Internet]
    Proxy[HTTPS Reverse Proxy]
    AS[Authorization Server]
    DB[(PostgreSQL)]
    Audit[(Audit Events)]
    Keys[Signing Keys]
    Ops[Health Probes]

    Internet --> Proxy --> AS
    AS --> DB
    AS --> Audit
    AS --> Keys
    Ops --> AS

The code

This part corresponds to these commits in the repository:

  • a977668 feat: harden runtime and CORS configuration
  • d2781ff feat: add health monitoring and security audit logs

Check out the last one to see the project exactly as this part leaves it:

git checkout d2781ff

References

Next: package the server for Docker, run the real integration suite in CI, and cut the v1.0.0 release.