API security · Spring Security
Secure API
Personal data behind authorization that actually checks the record
A subscriber API carrying personal data, with JWT authentication, object-level authorization, field-level encryption, per-caller rate limiting and an audit trail that records refusals as well as successes.
- Java 21
- Spring Security
- OAuth2 / JWT
- AES-GCM
Repository: secure-api
In short
- Object-level authorization: a valid token with someone else's record id gets nothing
- A foreign record and a missing one return byte-identical responses, so refusals cannot confirm which ids exist
- National IDs encrypted per field with AES-256-GCM, bound to the record so ciphertext cannot be moved between rows
- 38 tests, including a token bucket driven by an injected clock so nothing sleeps
- Runs standalone with no database or identity provider, and a script that mints tokens and walks the security behaviour
Authentication is the easy half
Validating a token is a library call. The question that follows — may this caller see this particular record — is the one APIs get wrong, and it is first on the OWASP API Security list because getting it wrong leaks everything at once.
It cannot be answered by a URL pattern or a scope, because the answer depends on the record rather than the route. A caller with a perfectly valid token and the correct scope, asking for an id belonging to another tenant, must be refused. The check therefore lives in the service, where the record is in hand, and the tenant comes from a claim in the token rather than from a parameter — a caller who can name their own tenant is self-certifying every check downstream.
Refusals that do not leak
A record belonging to another tenant and a record that does not exist return byte-identical responses. Answering 403 for one and 404 for the other confirms which ids are real, which is precisely what someone walking an id space is trying to learn — the refusal itself becomes the data.
The test does not check the status codes separately. It performs both requests and asserts the response bodies are equal, because that is the property that matters.
Encryption that assumes the attacker got in
National IDs are encrypted per field before storage, so whatever sits behind the repository — a table, a nightly backup, a read replica with looser access — never contains a readable one.
GCM rather than CBC, because it authenticates as well as encrypts: with CBC, an attacker able to modify stored ciphertext can flip bits in the plaintext and the application decrypts the result without complaint. A fresh IV per encryption, because deterministic encryption leaks equality — an observer could tell which subscribers share a value without decrypting anything. And the record id is bound in as additional authenticated data, so a ciphertext lifted from one row and pasted into another simply fails to decrypt.
Decryption failures are deliberately uninformative. Distinguishing wrong key from tampered from wrong record in the error message hands an attacker an oracle.
Rate limiting, and auditing the denials
The limiter is a token bucket keyed on the authenticated subject, not the IP address. Keying on IP punishes everyone behind a shared connection for one noisy client, and does nothing about a single credential used from many addresses — which is the shape of scraping and credential-stuffing traffic. A bucket rather than a fixed window, because a fixed window permits double the intended rate across its boundary.
Every access to personal data is audited, including refusals. One 403 is somebody mistyping an id; two hundred from one subject is an enumeration attack, and only the log tells them apart. The entries carry the record id and never its contents — an audit trail that quotes the data it protects has just copied it somewhere with weaker access controls.
Seeing it run
Captured from an actual run, not an illustration. The repository has the script that produced it.
$ mvn spring-boot:run$ ./demo/run-demo.sh 1. No token at all HTTP 401 2. Token with the wrong scope HTTP 403 3. Create a subscriber as tenant-a HTTP 201 {"id":"648a412f...","nationalId":"**********9012"} 4. Read it back without pii:read HTTP 200 {"nationalId":"**********9012"} 5. Read it back WITH pii:read HTTP 200 {"nationalId":"1234-5678-9012"} 6. tenant-b asks for tenant-a's record HTTP 404 {"error":"not found"} 7. tenant-b asks for an id that does not exist HTTP 404 {"error":"not found"} 8. A tampered token HTTP 401 9. Malformed national ID HTTP 400 {"fields":{"nationalId":"nationalId must be ####-####-####"}} 10. Rate limiting 200 requests in 0.12s HTTP 200: 105 HTTP 429: 95
Source
- src/main/java/com/dvpalmes/secureapi/service/SubscriberService.java
- src/main/java/com/dvpalmes/secureapi/crypto/FieldEncryptor.java
- src/test/java/com/dvpalmes/secureapi/web/SubscriberApiSecurityTest.java