Spring Boot Audit Logging: JPA Auditing & Custom Logging for Production Systems
Learn production-grade audit logging in Spring Boot 3.2.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
- ✓Java 17+ and Spring Boot 3.2+
- ✓Basic understanding of JPA entities and repositories
- ✓Spring Security (or at least SecurityContextHolder) for user context
- ✓A relational database (PostgreSQL 15+ or MySQL 8+)
• Use @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy for automatic JPA auditing
• Implement AuditorAware
Think of audit logging like the black box on an airplane. You don't need it when everything's smooth, but when something crashes, that box tells you exactly who did what and when. In a payment system, it's the difference between 'we lost $10k' and 'we know Alice's admin account deleted transaction #12345 at 3:02 AM UTC.'
If you've ever been called at 2 AM because a production payment batch went missing, you know audit logging isn't optional—it's survival. In 15 years of building SaaS billing systems, I've seen the same pattern: teams add basic JPA auditing, ship it, and then panic when a customer disputes a charge and they can't prove who modified the invoice. Spring Boot 3.2 with Jakarta Persistence 3.1 gives you @CreatedDate and @LastModifiedDate out of the box, but that's just the appetizer. The main course is building a custom audit trail that captures every state change—especially deletes, which JPA auditing ignores. This tutorial walks you through a real payment-processing domain: invoices, users, and transaction logs. We'll cover JPA entity auditing, custom AOP-based logging for service methods, storing audit records in a separate immutable table, and integrating with Spring Security to capture the actual user. I'll also share a production incident from 2021 where a misconfigured AuditorAware caused silent data loss. By the end, you'll have a copy-paste-ready audit system that passes PCI DSS and SOC 2 audits.
Setting Up JPA Auditing with Spring Boot 3.2
Let's start with the foundation: enabling JPA auditing in your Spring Boot application. Add @EnableJpaAuditing to your configuration class. Then create a base entity class that all your domain entities will extend. This base class will hold the four standard audit fields: createdDate, lastModifiedDate, createdBy, and lastModifiedBy. In a payment system, every invoice, transaction, and user profile should extend this. Use @EntityListeners(AuditingEntityListener.class) on the base class—this is the hook that Spring Data JPA uses to populate the fields automatically. The key thing: timestamps should be Instant (not LocalDateTime) for UTC consistency across time zones. For the user fields, you need an AuditorAware bean that returns the current user's identifier. In production, this comes from Spring Security's SecurityContextHolder. But here's the gotcha: if you have background jobs (like scheduled batch payments), the security context might be empty. Always handle that gracefully—return "SYSTEM" or "SCHEDULER" as a fallback. Never return null, because that will cause a ConstraintViolationException if your columns are NOT NULL.
What the Official Docs Won't Tell You
The Spring Data JPA documentation shows you how to annotate fields and configure AuditorAware. What it doesn't tell you: JPA auditing does NOT fire on entity deletion. The @PreRemove and @PostRemove lifecycle callbacks are not intercepted by AuditingEntityListener. This means if you rely on @LastModifiedBy to track who deleted a record, you'll get null—or worse, the value from the last update. In a billing system, this is catastrophic. Imagine a customer disputes a charge, and you need to prove that an admin deleted the invoice. Without a delete audit trail, you have no evidence. The official docs also gloss over performance: if you have 10,000 entities in a batch save, AuditingEntityListener will fire for each one, potentially causing N+1 queries if you're not careful. Another hidden detail: @CreatedDate and @LastModifiedDate use the JVM's current time by default. In a clustered deployment, if your servers' clocks drift, you'll get inconsistent timestamps. Always override with a clock bean. Finally, the docs assume you want auditing on every entity. In practice, you'll want to exclude certain read-only tables (like reference data) to avoid unnecessary overhead. Use @EntityListeners only on entities that need auditing, not on a base class if you have mixed needs.
Custom Audit Logging with Spring AOP
Since JPA auditing can't capture deletes or complex business operations (like 'user changed payment method'), you need a custom audit log. The most maintainable approach is Spring AOP with @Around advice on service methods. Create a custom annotation @Auditable that you can place on any service method. The annotation can accept parameters like action (e.g., 'DELETE_INVOICE', 'UPDATE_PAYMENT_METHOD') and entity type. Then, in the aspect, extract the method arguments, the current user (from SecurityContextHolder), and the result (or exception). Build an AuditEvent record and persist it to a dedicated audit_log table. This table should be append-only: never allow updates or deletes. Use a separate data source or at least a separate schema to enforce this. In the aspect, use @Around to capture both success and failure. If the method throws an exception, log the error details. This is invaluable for debugging production issues. For performance, batch the audit log inserts using a queue and a scheduled flush—otherwise, every business operation will cause a synchronous database write. In a high-traffic payment system, we used a BlockingQueue<AuditEvent> and a @Scheduled method that flushed every 5 seconds or when the queue reached 1000 items.
Audit Log Table Design for Append-Only Storage
The audit_log table must be immutable. That means no UPDATE, no DELETE, and no soft deletes. Use database-level permissions: create a separate database user that only has INSERT and SELECT on the audit schema. In PostgreSQL, you can use a trigger to prevent updates. The table schema should include: id (UUID primary key, generated in Java), correlation_id (UUID), user_id (varchar 100), action (varchar 50), entity_type (varchar 50), entity_id (varchar 100), old_value (jsonb), new_value (jsonb), error_message (text), created_at (timestamp with time zone). old_value and new_value are crucial for before/after snapshots. In your AOP aspect, serialize the method arguments and return value to JSON. For large entities, only include changed fields to save space. Index on (user_id, created_at) and (entity_type, entity_id) for query performance. In production, we partitioned the table by month using PostgreSQL's declarative partitioning. This made purging old data (for GDPR compliance) trivial. Also, never put audit_log in the same tablespace as your transactional data. If a transaction rolls back, the audit log should still be committed. Use a separate transaction manager for audit operations—REQUIRES_NEW propagation.
Integrating with Spring Security for User Context
Audit logging is useless if you can't tie actions to a real person. In Spring Boot, the standard way is to extract the username from SecurityContextHolder. But in a microservices environment with JWT tokens, the username might be a UUID, not an email. Standardize on a user identifier that is meaningful across services. I recommend using the user's email or a business key (like employee ID) rather than a database primary key, because primary keys can change during data migration. In the AuditorAware bean, extract the JWT token from the security context, parse it, and return the 'sub' claim. For service-to-service calls (e.g., using Spring Cloud Feign), propagate the user context via a custom header (X-User-Id). In your API gateway, set this header from the JWT. Then in your audit aspect, read this header if the security context is empty. This ensures that even internal service calls are audited with the originating user. For batch jobs, use a dedicated system user like 'BATCH_JOB_DAILY_RECONCILIATION'. Never use 'anonymous'—that's a red flag in any audit. In production, we also logged the IP address and user-agent from the HTTP request for compliance.
Testing Audit Logging in CI/CD Pipelines
Audit logging is often treated as a cross-cutting concern and left untested. That's a mistake. In production, the audit trail is your single source of truth. You need tests that verify: (1) JPA auditing populates timestamps and user fields on create/update, (2) the AOP aspect logs the correct action and entity ID, (3) the audit log table is append-only (no updates), and (4) rollback scenarios don't lose audit entries. Use @DataJpaTest for the repository layer and @SpringBootTest with a real test database (Testcontainers with PostgreSQL 15). For the AOP aspect, use a mock service method annotated with @Auditable and verify that AuditLogRepository.save() was called with the correct arguments. Also test the negative case: when the service method throws an exception, the audit event should still be saved. In CI, run these tests in a separate Maven profile (e.g., -P audit-test) that spins up a dedicated PostgreSQL container. This prevents flaky tests due to shared databases. One more thing: test that your audit table partitioning works. Insert a record with a timestamp from 6 months ago and verify it goes into the correct partition. We learned this the hard way when a partition was misconfigured and all new audit records silently failed to insert.
Production Debugging with Audit Logs
When something goes wrong in production, your audit log is the first place you look. But raw SQL queries on an audit table are slow and error-prone. Build a simple admin endpoint that exposes audit events with filters: by user, action, entity type, date range, and correlation ID. Use Spring Data JPA Specifications or QueryDSL for dynamic queries. Cache the results for 30 seconds because audit data doesn't change. In a recent incident, a customer reported that their subscription was downgraded without their knowledge. We queried the audit log by customer_id and found a 'CHANGE_PLAN' action by an admin user at 3 AM. The admin had accidentally applied a bulk update to the wrong segment. Without the audit log, we would have blamed the customer. Also, use the correlation ID to trace the request across microservices. In the audit event, store the HTTP request path and method. This helps reconstruct the exact API call that caused the change. For real-time monitoring, stream audit events to a Kafka topic and consume them in a fraud detection service. If you see 100 'DELETE_INVOICE' actions in 5 minutes from the same user, that's a red flag. We built a simple alert that sends a Slack message when the rate of audit events from a single user exceeds a threshold.
Performance Optimization and Pitfalls
Audit logging can become a performance bottleneck if not designed carefully. The biggest mistake: synchronous inserts into the audit table for every business operation. In a system with 1000 TPS, this means 1000 additional database writes per second. Use a queue-based approach: the AOP aspect puts AuditEvent objects into a BlockingQueue, and a scheduled task flushes the queue every 100ms or when it reaches 500 items. Use @Async with a dedicated thread pool to avoid blocking the main request thread. Another pitfall: storing full entity snapshots in old_value and new_value. For a large entity with 50 fields, this can be 2KB per event. Over a day, that's gigabytes. Instead, compute a diff and store only the changed fields. Use Jackson's @JsonInclude(Include.NON_NULL) on the snapshot objects. Also, index your audit table properly. The most common query pattern is by user_id and created_at. Use a composite index on (user_id, created_at DESC). If you query by entity_type and entity_id, add another composite index. In PostgreSQL, consider using BRIN indexes on created_at if the table is append-only and large. Finally, set a TTL policy. Audit data older than 90 days (or whatever your compliance requires) should be moved to cold storage (S3, Glacier). Use a scheduled job that partitions and exports old data. We used pg_partman for automatic partition management.
The Silent Delete: How Missing Audit Logs Cost a Fintech $50k
- Never rely solely on JPA auditing for deletions—it doesn't fire on
remove(). - Always store audit records in an append-only table with a foreign key to the original entity.
- Use a correlation ID (UUID) per request to trace audit entries across services.
Check application logs for 'JPA auditing enabled' messageVerify entity has @EntityListeners(AuditingEntityListener.class)| File | Command / Code | Purpose |
|---|---|---|
| AuditConfig.java | @Configuration | Setting Up JPA Auditing with Spring Boot 3.2 |
| BaseAuditEntity.java | @MappedSuperclass | What the Official Docs Won't Tell You |
| AuditAspect.java | @Aspect | Custom Audit Logging with Spring AOP |
| AuditEvent.java | @Entity | Audit Log Table Design for Append-Only Storage |
| SecurityAuditorAware.java | @Component | Integrating with Spring Security for User Context |
| AuditLoggingTest.java | @SpringBootTest | Testing Audit Logging in CI/CD Pipelines |
| AuditController.java | @RestController | Production Debugging with Audit Logs |
| AuditQueueFlusher.java | @Component | Performance Optimization and Pitfalls |
Key takeaways
Interview Questions on This Topic
Explain how @CreatedDate and @LastModifiedDate work internally in Spring Data JPA.
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
That's Spring Boot. Mark it forged?
6 min read · try the examples if you haven't