Security and compliance are often treated as the work that happens at the end of a project, a checklist an auditor reviews just before go-live. In regulated domains such as fintech, digital banking, and healthcare, that approach fails quickly. Security controls bolted on late are expensive, fragile, and hard to prove. Compliance requirements such as PCI DSS, SOC 2, GDPR, and ISO 27001 are not satisfied by a single tool or a single policy document. They are satisfied by a set of engineering principles applied consistently across the system.
This article covers the principles I rely on when designing Java and Spring Boot services that must be both secure and auditable. The goal is not to make you a security specialist. It is to give you a practical vocabulary and a set of patterns you can apply to the services you build every day.
Why Security and Compliance Are Engineering Problems
A compliance framework describes outcomes: customer data must be protected, access must be controlled, changes must be traceable, and incidents must be detectable. It rarely prescribes implementation. That gap is where engineering decisions matter. Two systems can both claim to "encrypt personal data," yet one uses a static key checked into a repository and the other uses managed keys with rotation and audited access. Only the second one survives a serious review.
Treating compliance as an engineering property has three practical consequences:
- Controls must be designed into the architecture, not added afterward.
- Evidence must be generated automatically, because manual evidence collection does not scale and drifts over time.
- Defaults must be safe, because a secure system that requires every developer to remember every rule will eventually fail.
Principle 1: Least Privilege
Every identity in the system, whether a human, a service account, or a workload, should have only the permissions it needs to perform its function, and only for as long as it needs them.
In a Spring Boot application, this begins with the way you model roles and authorities. Avoid broad roles such as ADMIN that bundle unrelated capabilities. Prefer fine-grained authorities that map to business operations.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/accounts/**")
.hasAuthority("SCOPE_accounts.read")
.requestMatchers(HttpMethod.POST, "/api/transfers/**")
.hasAuthority("SCOPE_transfers.write")
.requestMatchers("/actuator/health").permitAll()
.anyRequest().denyAll())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
Notice the final rule: anyRequest().denyAll(). Deny-by-default is one of the most important security defaults you can set. A new endpoint added by a developer who forgets to configure access is closed until someone deliberately opens it.
Method-level checks add a second layer, so that authorization is enforced close to the business logic rather than only at the URL level:
@Service
public class TransferService {
@PreAuthorize("hasAuthority('SCOPE_transfers.write') and " +
"#command.sourceAccountOwnerId == authentication.name")
public TransferReceipt transfer(TransferCommand command) {
// business logic
return null;
}
}
For service-to-service calls, the same principle applies. Each service should authenticate with its own identity, typically through OAuth 2.0 client credentials, and receive only the scopes it needs. Shared service accounts with broad access are a common audit finding, and they make incident investigation nearly impossible.
Principle 2: Defense in Depth
No single control is reliable on its own. Authentication can be bypassed by a misconfiguration. Input validation can miss an edge case. Network filtering can be circumvented from inside. Defense in depth means layering controls so that the failure of one does not expose the system entirely.
For a typical service, the layers look like this:
- Edge: TLS termination, web application firewall rules, and rate limiting.
- Gateway or ingress: Token validation and coarse-grained access checks.
- Service boundary: Authorization at the endpoint and input validation.
- Business logic: Domain rules that reject invalid state transitions regardless of how the request arrived.
- Data layer: Encryption at rest, row-level or tenant-level isolation, and restricted database credentials.
Input validation deserves special attention because it is where many vulnerabilities start. Validate at the boundary using explicit constraints, and never trust that upstream components have already done it:
public record CreateAccountRequest(
@NotBlank
@Size(max = 100)
@Pattern(regexp = "^[\\p{L} .'-]+$")
String holderName,
@NotNull
@Pattern(regexp = "^[A-Z]{3}$")
String currencyCode,
@NotNull
@DecimalMin(value = "0.00")
@Digits(integer = 12, fraction = 2)
BigDecimal initialDeposit
) {}
Validation rejects malformed data, but it does not replace parameterized queries. Always use prepared statements or the framework's parameter binding for any value that reaches the database. String concatenation in SQL remains one of the most avoidable vulnerabilities in enterprise Java code.
// Vulnerable: never do this
String sql = "SELECT * FROM accounts WHERE holder_name = '" + name + "'";
// Safe: parameter binding
@Query("SELECT a FROM Account a WHERE a.holderName = :name")
List<Account> findByHolderName(@Param("name") String name);
Principle 3: Secure by Default
Security should work without requiring developers to make the right choice every time. This principle shapes how you configure libraries, how you build shared components, and how you design internal platforms.
A few examples of secure defaults:
- Deny access unless explicitly granted, as shown above.
- Disable verbose error messages in production, so stack traces and SQL details never reach clients.
- Use
SecureRandomfor any security-sensitive value, neverjava.util.Random. - Set HTTP security headers such as
Strict-Transport-SecurityandX-Content-Type-Optionscentrally. - Require encryption for connections to databases and message brokers.
Error handling is a good place to enforce this. A global exception handler can return a generic message to the client while logging the detail internally:
@RestControllerAdvice
public class GlobalErrorHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalErrorHandler.class);
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleUnexpected(Exception ex, HttpServletRequest request) {
String correlationId = UUID.randomUUID().toString();
log.error("Unhandled error correlationId={} path={}", correlationId,
request.getRequestURI(), ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiError("INTERNAL_ERROR",
"An unexpected error occurred. Reference: " + correlationId));
}
}
The correlation identifier is important for compliance too. It lets support staff connect a user-visible error to a detailed internal log entry without exposing the internal detail to the client.
Principle 4: Data Classification and Minimization
You cannot protect data you have not identified. Start by classifying the data your system handles. A simple scheme is usually enough:
- Public: Information intended for anyone.
- Internal: Operational data not intended for external parties.
- Confidential: Business-sensitive data, such as pricing or contract terms.
- Restricted: Personal data, financial account details, and credentials.
Once data is classified, apply handling rules to each class. Restricted data should be encrypted, excluded from logs, masked in user interfaces, and retained only as long as required.
Data minimization is the companion principle. The most secure data is data you never collected. Ask whether each field in a request, a table, or an event is necessary for the purpose it serves. Removing unnecessary fields reduces breach impact and simplifies regulatory obligations under frameworks such as GDPR.
Logging deserves specific attention. Logs are a common leak path because they are widely copied and rarely access-controlled. Mask sensitive values before they reach the logger:
public final class Masking {
private Masking() {}
public static String maskAccountNumber(String accountNumber) {
if (accountNumber == null || accountNumber.length() <= 4) {
return "****";
}
String last4 = accountNumber.substring(accountNumber.length() - 4);
return "*".repeat(accountNumber.length() - 4) + last4;
}
}
// Usage
log.info("Transfer initiated from account {}", Masking.maskAccountNumber(account));
Principle 5: Encryption Done Correctly
Encryption is frequently misused. The most common mistakes are using outdated algorithms, reusing initialization vectors, and storing keys next to the data they protect.
For symmetric encryption of application data, AES in GCM mode provides both confidentiality and integrity. The following example uses the standard Java Cryptography Architecture:
public final class FieldEncryptor {
private static final int GCM_TAG_BITS = 128;
private static final int IV_BYTES = 12;
private final SecretKey key;
private final SecureRandom random = new SecureRandom();
public FieldEncryptor(SecretKey key) {
this.key = key;
}
public byte[] encrypt(byte[] plaintext) throws GeneralSecurityException {
byte[] iv = new byte[IV_BYTES];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
byte[] ciphertext = cipher.doFinal(plaintext);
// Prepend the IV so decryption can recover it
ByteBuffer buffer = ByteBuffer.allocate(iv.length + ciphertext.length);
buffer.put(iv).put(ciphertext);
return buffer.array();
}
public byte[] decrypt(byte[] payload) throws GeneralSecurityException {
ByteBuffer buffer = ByteBuffer.wrap(payload);
byte[] iv = new byte[IV_BYTES];
buffer.get(iv);
byte[] ciphertext = new byte[buffer.remaining()];
buffer.get(ciphertext);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
return cipher.doFinal(ciphertext);
}
}
Three details matter here. The IV is generated randomly for every encryption and never reused with the same key. The authentication tag is verified on decryption, so tampered ciphertext is rejected. And the key is not in this class at all. It should come from a key management service such as AWS KMS, Google Cloud KMS, OCI Vault, or an HSM, and it should be rotated on a schedule.
Envelope encryption is the pattern that makes key management practical at scale. Your service generates a data key, encrypts the data with it, and then asks the key management service to encrypt the data key with a master key. Only the wrapped data key is stored alongside the ciphertext. Rotating the master key does not require re-encrypting every record.
Transport security is equally important. Enforce TLS for all service communication, and configure the Java truststore deliberately rather than trusting every certificate. Disabling certificate validation to "fix" a connection error is a finding in almost every security review, and it is never the right fix.
Principle 6: Audit Logging and Traceability
Compliance frameworks require you to answer questions after the fact. Who accessed this record? Who approved this change? When did this configuration become active? If your system cannot answer these questions, you cannot prove compliance, and you cannot investigate incidents effectively.
An audit record should capture the actor, the action, the target, the outcome, and the time. It should be written in a way that cannot be silently altered:
public record AuditEvent(
String eventId,
Instant occurredAt,
String actor,
String action,
String resourceType,
String resourceId,
String outcome,
String correlationId
) {}
@Component
public class AuditRecorder {
private final AuditEventRepository repository;
public AuditRecorder(AuditEventRepository repository) {
this.repository = repository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String actor, String action, String resourceType,
String resourceId, String outcome) {
repository.save(new AuditEvent(
UUID.randomUUID().toString(),
Instant.now(),
actor,
action,
resourceType,
resourceId,
outcome,
MDC.get("correlationId")));
}
}
Two design choices help here. Using REQUIRES_NEW means the audit record persists even if the business transaction rolls back, which is often exactly what an investigator needs to see: an attempted action that failed. And audit events should be append-only at the storage level. Restrict the application's database role to insert and select on the audit table, with no update or delete privileges.
Audit logs should also be forwarded to a system the application team cannot modify, such as a centralized SIEM. A log that only the system under review can edit is not much evidence.
Principle 7: Separation of Duties
No single person or process should be able to complete a sensitive operation alone. In financial systems, the classic example is requiring one person to create a payment and a different person to approve it. In software delivery, the equivalent is requiring peer review before code reaches production.
In application code, enforce this with explicit state transitions and checks on who performed each step:
public void approveTransfer(String transferId, String approverId) {
Transfer transfer = repository.findById(transferId)
.orElseThrow(() -> new NotFoundException("Transfer not found"));
if (transfer.getCreatedBy().equals(approverId)) {
throw new SeparationOfDutiesException(
"Approver cannot be the same user who created the transfer");
}
transfer.approve(approverId);
repository.save(transfer);
}
The check is simple, but it encodes a compliance requirement directly in the domain logic, where it cannot be bypassed by a different endpoint or script.
Principle 8: Secure Software Supply Chain
Your code is only as trustworthy as its dependencies and its build pipeline. Several high-profile incidents have started with a compromised library or build tool, so this is now a standard part of security reviews.
Practical steps include:
- Pin dependency versions and review updates rather than accepting floating ranges.
- Scan dependencies for known vulnerabilities in the pipeline, using tools such as OWASP Dependency-Check, Snyk, or the scanners built into your repository platform.
- Generate a Software Bill of Materials so you can answer quickly whether a newly disclosed vulnerability affects your services.
- Sign and verify artifacts, and restrict who can publish to production registries.
- Protect secrets by never committing them to source control. Use a secrets manager and inject values at runtime.
Secret scanning in pre-commit hooks or in the CI pipeline catches many leaks before they reach the remote repository, which is far cheaper than rotating credentials after a breach.
Mapping Principles to Compliance Frameworks
These principles are not a substitute for reading the framework that applies to you, but they map well to common requirements:
- PCI DSS requires strong access control, encryption of cardholder data, logging of access, and regular testing. Least privilege, encryption, and audit logging address much of this directly.
- SOC 2 evaluates controls across security, availability, processing integrity, confidentiality, and privacy. Evidence from automated audit trails and pipeline controls is central to demonstrating them.
- GDPR emphasizes data minimization, purpose limitation, the right to erasure, and breach notification. Data classification and minimization are the starting points.
- ISO 27001 is a management system standard. It rewards documented risk assessments and continuous improvement, which pair naturally with automated controls.
The key insight is that many requirements across these frameworks reduce to the same engineering capabilities: know your data, restrict access, encrypt it, record what happens to it, and verify that your controls still work.
Making Compliance Continuous
Compliance is not a state you reach once. It is a condition you maintain as the system changes. Three practices help sustain it:
- Policy as code. Express access rules, configuration baselines, and infrastructure constraints in version-controlled form, and check them automatically in the pipeline.
- Automated evidence. Generate audit reports, access reviews, and change records from the systems themselves, rather than assembling them by hand before an audit.
- Regular control testing. Run security tests, including dependency scans, dynamic application testing, and access reviews, on a fixed schedule, and track findings until they are closed.
Key Takeaways
- Security and compliance are engineering properties. Design them into the architecture from the start.
- Apply least privilege and deny by default, both at the URL level and in business logic.
- Validate input at the boundary, and always bind parameters rather than concatenating queries.
- Classify data, minimize what you collect, and mask sensitive values in logs.
- Use authenticated encryption such as AES-GCM, with keys managed outside the application and rotated regularly.
- Write audit records that survive rollbacks and cannot be altered by the application.
- Enforce separation of duties in the domain model, not just in process documents.
- Treat your supply chain and secrets as part of your security perimeter.
- Automate evidence collection and control testing so compliance stays true as the system evolves.
Security and compliance can feel like a tax on delivery, but the teams that treat them as design constraints tend to ship faster, not slower. The rework, incident response, and audit remediation they avoid add up quickly. Start with one principle, apply it to a service you own this week, and expand from there.
Top comments (1)
Great breakdown, Said! The point about generating audit evidence automatically really stood out. That's something teams can easily overlook until an actual security review happens.
One interesting Spring Boot gotcha:
@Transactional(REQUIRES_NEW)only creates a separate transaction when the call goes through the Spring proxy. Self-invocation can quietly break that guarantee.I'm curious, how would you approach testing these controls in CI/CD? Would you rely more on integration tests or policy-as-code checks? Would love to hear your take!