Java Application Security Basics
Purpose
The code-level half of application security — the decisions that survive replacing the
security framework. It prevents two failures: "I used the framework default" mistaken for
"I followed current guidance", and authorisation that lives only at the HTTP entry point.
Scope
Covers: password storage and verification, secure randomness, authorisation as a
precondition of the domain operation, the adversarial half of input validation, secrets in
source and in types, and safe review boundaries for reversible cryptography.
Does not cover: transport security, nor framework configuration — filter chains, JWT and
OAuth2 resource server, method-security wiring and CORS belong to spring-security-for-apis,
in a different repository, not installed alongside this skill. Nor does it apply to a service
with no credential store, no untrusted input and no per-instance ownership rule: threading an
Actor through that domain is cost, no benefit.
Workflow
Before recommending APIs or parameters, inspect the project's compiler release/toolchain,
resolved security-library versions, runtime image, credential format and binding standard.
Java 21 is this skill's example baseline, not permission to upgrade the target or add Spring
or BouncyCastle. Version-specific facts below are dated examples; verify them against the
resolved version. If evidence is missing, state the gap and keep the proposed change conditional.
Name the asset and the reachable attacker. "A leaked database backup" and "another
tenant's authenticated user" lead to different code; "make it more secure" leads to none.
Read the KDF parameters, not the class name. Spring Security's own defaults sit below
current OWASP guidance, so "I used PasswordEncoderFactories" is not a compliance claim:
|
Spring Security 7.1 default |
OWASP (fetched 2026-08-27) |
| Argon2id |
m=16384 KiB, t=2, p=1 (defaultsForSpringSecurity_v5_8) |
m=19456 KiB at t=2, p=1 |
| PBKDF2-HMAC-SHA256 |
310,000 iterations |
600,000 iterations |
| bcrypt |
strength 10 |
10 is the stated minimum |
DelegatingPasswordEncoder.idForEncode is still "bcrypt" in 7.1.1, not Argon2id — so
"Spring defaults to the OWASP-recommended algorithm" is wrong on both algorithm and
parameters. new Argon2PasswordEncoder(16, 32, 1, 19456, 2) closes the Argon2 gap.
Make authorisation a precondition of the domain operation — it takes the acting
principal and refuses, rather than trusting that the caller which checked is the only
caller. The parameter is a compile-time obligation, not proof of identity: construct it only
from a trusted authentication context, and do not let request JSON supply roles or tenant.
Keep the controller annotation as cheap early rejection; it stops being the only check.
Authorise the instance: "has role CUSTOMER" without "and this order is theirs".
Allowlist attacker-controlled input as structure, but it is not the control — regex over
hostile input is a DoS vector (java-strings-and-text); hostile bytes are
java-serialization-hardening.
Trace every secret from where it enters to everywhere it can be rendered — source,
config, toString(), equals, an exception message, an HTTP error body. Redaction at the
log encoder is structured-logging's backstop; keeping it out of the type is the control.
Verify against references/review.md — a rule you cannot check on a diff is no rule.
Decision rules
IF greenfield password storage
THEN Argon2id at OWASP parameters, not the encoder's defaults.
IF existing bcrypt at a measured adequate cost
THEN keep verification support and migrate on successful authentication; schedule forced
migration only when compliance, compromise evidence, an unacceptable cracking model,
inactive accounts or the 72-byte legacy estate justify its user and operational cost.
IF a caller identity or resource owner is read from the request
THEN it is a claim, not an identity: take the subject from the authenticated principal.
IF comparing fixed-width hashes, MACs or token digests
THEN MessageDigest.isEqual (or the vetted library verifier), never Arrays.equals or
String.equals; reject or canonicalise representation before decoding and keep compared
lengths fixed. Passwords go through PasswordEncoder.matches, not a digest comparison.
IF a value must be unguessable (session id, reset token, API key, OTP, salt)
THEN generate an explicit entropy budget with SecureRandom; never Random, ThreadLocalRandom or
Math.random(). Store reset/API tokens as a digest, bind purpose and subject, expire them,
and consume single-use tokens atomically. Reusable API keys instead need revocation and
rotation; do not consume them on their first authenticated request.
IF plaintext must be recovered later
THEN define the threat model and key custody first; use a vetted AEAD construction with an
explicit transformation, unique nonce per key and versioned envelope. Never use
Cipher.getInstance("AES"), ECB, unauthenticated CBC, or a reusable fixed GCM nonce.
Rules
- Password length policy conflicts between the two standards teams cite, so say which one binds
instead of picking silently: NIST SP 800-63B-4 (26 Aug 2025) makes 15 characters a SHALL
where the password is the single factor (8 within MFA); ASVS 5.0 §6.2.1 sets the floor at
8, 15 recommended. The deciding question is not which is stricter but which regime does this
system answer to, and is the password ever the only factor? Both forbid composition rules
and periodic rotation regardless — and never write that "NIST relaxed the password rules": it
dropped composition and expiry and raised the single-factor floor. Requirement text and
the three questions that settle it:
references/password-policy.md.
- bcrypt's 72-byte ceiling now fails loudly, not silently: since the CVE-2025-22228 fix
(6.3.8 / 6.4.4, March 2025)
encode throws IllegalArgumentException above it, so a form
advertising 128-character passphrases 500s at sign-up, and any re-encode — password change,
or a rehash under upgradeEncoding — throws for users whose stored hash predates the fix.
matches still skips the guard, so legacy hashes keep the ASVS 6.2.8 truncation. Cap at 72
bytes at the boundary, or use Argon2id.
- Make account-existence paths observationally similar: the same external response, one KDF
on both paths, and shared throttling. This mitigates rather than proves indistinguishability:
caches, database work, network jitter and downstream side effects remain measurable.
orElseThrow() before comparison — or distinct not-found/not-permitted errors — is an
enumeration oracle.
- Treat authorisation and mutation as one consistency decision. A check against an object read
in one transaction followed by an unconditional write in another is a TOCTOU bug; use a
transaction with the required isolation, a version/CAS predicate, or a conditional update
that includes tenant/owner and expected state. Returning
404 for both absent and forbidden
resources hides detail from the caller but is not the authorisation control.
- Password-reset and API-key flows are credential systems, not random-string helpers. Generate
at least the entropy required by the applicable standard, display the raw value once, store
only a domain-separated digest and enforce purpose/subject/expiry. Atomically consume reset
tokens; enforce revocation and rotation for reusable API keys.
Rate-limit redemption; do not log query strings containing bearer material.
- Hash what only needs equality verification; encrypt only what the application must recover.
With reversible data,
Cipher.getInstance("AES") delegates mode and padding to the provider.
Prefer a platform/KMS envelope or a reviewed AES/GCM/NoPadding/ChaCha20-Poly1305 facility;
authenticate tenant, record id, schema and key version as AAD where those fields must not be
swappable. Nonce uniqueness is per key, and decrypt must release no plaintext before tag
verification. Store algorithm, key version, nonce and ciphertext/tag so rotation is possible;
never store a plaintext data-encryption key beside the ciphertext it protects. An encrypted
(wrapped) data key may accompany the ciphertext when its wrapping key is separately protected.
- A record component or Lombok
@Data field holding a secret is in toString() by
construction, and log.info("processing {}", request) is then a leak nobody wrote.
- Two moves make code worse. Encrypting what only needs hashing ("so we can support
password recovery") trades away the property that mattered — a dump yields no passwords —
for a feature that is itself a defect, and adds a key needing rotation and custody. The
custom crypto wrapper,
CryptoUtils.hash(String) "for flexibility", has a signature too
narrow for Argon2's parameters and drops the algorithm identifier the {id} format carries
per hash, destroying the migration path it was built for — that, not implementing AES, is
rolling your own crypto.
Failure modes and production evidence
| Symptom |
Distinguish with |
Likely remediation |
| Login latency or CPU jumps after a KDF change |
KDF duration histogram by encoded algorithm id; auth concurrency and CPU saturation |
Bound authentication concurrency, benchmark on production-class hardware, then tune parameters without dropping below the binding floor |
| Known users and unknown users have separable latency |
Distributions, not one stopwatch sample; include warm/cold cache paths |
Dummy hash with current parameters, common response path and rate limiting; remove existence-specific downstream work |
| Cross-tenant mutation despite role checks |
Audit subject, tenant, resource owner and write predicate; enumerate every caller |
Derive subject from trusted context and include owner/tenant/version in the transactional write condition |
| Reset link works twice or after replacement |
Concurrent redemption and replay tests against the real datastore |
Digest-at-rest, expiry and one atomic consume/update; invalidate older outstanding tokens intentionally |
| Secret appears after an exception |
Structured-log and error-contract tests with canary secrets |
Secret-free value types/messages, allowlisted error mapping and encoder-side redaction as a backstop |
Do not benchmark password verification with JMH alone and call the capacity question solved.
Measure the primitive to choose parameters, then load-test the bounded authentication path:
arrival bursts, dummy-hash misses, rehash-on-login, datastore latency and rate limiting determine
whether an attacker can turn the KDF into a CPU or memory-exhaustion endpoint.
Deliverable
For each actionable finding, return its source location, reachable attacker and consequence,
proposed adjustment, and the test that would verify it. Separate observed behavior from static
inference; grep matches alone do not prove exploitability. Report tests actually run and gaps.
References
- Password storage — OWASP parameter tables, the
Argon2id-versus-bcrypt disagreement and its JVM complication, Spring Security encoder facts,
peppering, randomness,
char[] versus String. Read at step 2, and before designing a type
that holds a credential.
- Password policy — NIST 800-63B-4 and ASVS 5.0 requirement
text side by side. Read when setting registration or change-password rules.
- Before and after — the non-constant-time comparison in Urma &
Warburton's Twootr chapter and its fix, and authorisation moved into the domain. Steps 2, 3.
- Review prompts and verification — the failure catalogue as
questions and grep patterns, and how to tell the change improved something. Read at step 6.
1---2name: java-application-security-basics3description: Application-security judgement for Java 21+: password storage with current memory-hard KDF parameters, constant-time verification, secure randomness, authorisation inside the protected operation, adversarial validation, reversible-cryptography boundaries, and secret-safe types. Use when a password, hash, salt, token, API key or pepper appears in a diff; when MessageDigest, SecureRandom, Random, UUID, Cipher, Mac or PasswordEncoder is called; when a controller annotation is the only authorisation check; when identity comes from the request instead of the principal; or when a generic CryptoUtils wrapper is proposed. Code-level only: layered validation is java-defensive-programming, redaction is structured-logging, ReDoS is java-strings-and-text, and deserialisation is java-serialization-hardening.4---56# Java Application Security Basics78## Purpose910The code-level half of application security — the decisions that survive replacing the11security framework. It prevents two failures: "I used the framework default" mistaken for12"I followed current guidance", and authorisation that lives only at the HTTP entry point.1314## Scope1516**Covers:** password storage and verification, secure randomness, authorisation as a17precondition of the domain operation, the adversarial half of input validation, secrets in18source and in types, and safe review boundaries for reversible cryptography.1920**Does not cover:** transport security, nor framework configuration — filter chains, JWT and21OAuth2 resource server, method-security wiring and CORS belong to `spring-security-for-apis`,22in a different repository, not installed alongside this skill. Nor does it apply to a service23with no credential store, no untrusted input and no per-instance ownership rule: threading an24`Actor` through that domain is cost, no benefit.2526## Workflow2728Before recommending APIs or parameters, inspect the project's compiler release/toolchain,29resolved security-library versions, runtime image, credential format and binding standard.30Java 21 is this skill's example baseline, not permission to upgrade the target or add Spring31or BouncyCastle. Version-specific facts below are dated examples; verify them against the32resolved version. If evidence is missing, state the gap and keep the proposed change conditional.33341. **Name the asset and the reachable attacker.** "A leaked database backup" and "another35 tenant's authenticated user" lead to different code; "make it more secure" leads to none.362. **Read the KDF parameters, not the class name.** Spring Security's own defaults sit below37 current OWASP guidance, so "I used `PasswordEncoderFactories`" is not a compliance claim:3839 | | Spring Security 7.1 default | OWASP (fetched 2026-08-27) |40 | ------------------ | ---------------------------------------------------------- | --------------------------- |41 | Argon2id | `m=16384 KiB, t=2, p=1` (`defaultsForSpringSecurity_v5_8`) | `m=19456 KiB` at `t=2, p=1` |42 | PBKDF2-HMAC-SHA256 | 310,000 iterations | 600,000 iterations |43 | bcrypt | strength 10 | 10 is the stated _minimum_ |4445 `DelegatingPasswordEncoder.idForEncode` is still `"bcrypt"` in 7.1.1, not Argon2id — so46 "Spring defaults to the OWASP-recommended algorithm" is wrong on both algorithm and47 parameters. `new Argon2PasswordEncoder(16, 32, 1, 19456, 2)` closes the Argon2 gap.48493. **Make authorisation a precondition of the domain operation** — it takes the acting50 principal and refuses, rather than trusting that the caller which checked is the only51 caller. The parameter is a compile-time obligation, not proof of identity: construct it only52 from a trusted authentication context, and do not let request JSON supply roles or tenant.53 Keep the controller annotation as cheap early rejection; it stops being the only check.54 Authorise the _instance_: "has role CUSTOMER" without "and this order is theirs".554. **Allowlist attacker-controlled input as structure, but it is not the control** — regex over56 hostile input is a DoS vector (`java-strings-and-text`); hostile bytes are57 `java-serialization-hardening`.585. **Trace every secret from where it enters to everywhere it can be rendered** — source,59 config, `toString()`, `equals`, an exception message, an HTTP error body. Redaction at the60 log encoder is `structured-logging`'s backstop; keeping it out of the type is the control.616. **Verify** against `references/review.md` — a rule you cannot check on a diff is no rule.6263## Decision rules6465```text66IF greenfield password storage67THEN Argon2id at OWASP parameters, not the encoder's defaults.6869IF existing bcrypt at a measured adequate cost70THEN keep verification support and migrate on successful authentication; schedule forced71 migration only when compliance, compromise evidence, an unacceptable cracking model,72 inactive accounts or the 72-byte legacy estate justify its user and operational cost.7374IF a caller identity or resource owner is read from the request75THEN it is a claim, not an identity: take the subject from the authenticated principal.7677IF comparing fixed-width hashes, MACs or token digests78THEN MessageDigest.isEqual (or the vetted library verifier), never Arrays.equals or79 String.equals; reject or canonicalise representation before decoding and keep compared80 lengths fixed. Passwords go through PasswordEncoder.matches, not a digest comparison.8182IF a value must be unguessable (session id, reset token, API key, OTP, salt)83THEN generate an explicit entropy budget with SecureRandom; never Random, ThreadLocalRandom or84 Math.random(). Store reset/API tokens as a digest, bind purpose and subject, expire them,85 and consume single-use tokens atomically. Reusable API keys instead need revocation and86 rotation; do not consume them on their first authenticated request.8788IF plaintext must be recovered later89THEN define the threat model and key custody first; use a vetted AEAD construction with an90 explicit transformation, unique nonce per key and versioned envelope. Never use91 Cipher.getInstance("AES"), ECB, unauthenticated CBC, or a reusable fixed GCM nonce.92```9394## Rules9596- Password length policy conflicts between the two standards teams cite, so say which one binds97 instead of picking silently: NIST SP 800-63B-4 (26 Aug 2025) makes 15 characters a **SHALL**98 where the password is the _single_ factor (8 within MFA); ASVS 5.0 §6.2.1 sets the floor at99 8, 15 recommended. The deciding question is not which is stricter but _which regime does this100 system answer to, and is the password ever the only factor?_ Both forbid composition rules101 and periodic rotation regardless — and never write that "NIST relaxed the password rules": it102 dropped composition and expiry and **raised** the single-factor floor. Requirement text and103 the three questions that settle it: `references/password-policy.md`.104- bcrypt's 72-**byte** ceiling now fails loudly, not silently: since the CVE-2025-22228 fix105 (6.3.8 / 6.4.4, March 2025) `encode` throws `IllegalArgumentException` above it, so a form106 advertising 128-character passphrases 500s at sign-up, and any re-encode — password change,107 or a rehash under `upgradeEncoding` — throws for users whose stored hash predates the fix.108 `matches` still skips the guard, so legacy hashes keep the ASVS 6.2.8 truncation. Cap at 72109 bytes at the boundary, or use Argon2id.110- Make account-existence paths observationally similar: the same external response, one KDF111 on both paths, and shared throttling. This mitigates rather than proves indistinguishability:112 caches, database work, network jitter and downstream side effects remain measurable.113 `orElseThrow()` before comparison — or distinct not-found/not-permitted errors — is an114 enumeration oracle.115- Treat authorisation and mutation as one consistency decision. A check against an object read116 in one transaction followed by an unconditional write in another is a TOCTOU bug; use a117 transaction with the required isolation, a version/CAS predicate, or a conditional update118 that includes tenant/owner and expected state. Returning `404` for both absent and forbidden119 resources hides detail from the caller but is not the authorisation control.120- Password-reset and API-key flows are credential systems, not random-string helpers. Generate121 at least the entropy required by the applicable standard, display the raw value once, store122 only a domain-separated digest and enforce purpose/subject/expiry. Atomically consume reset123 tokens; enforce revocation and rotation for reusable API keys.124 Rate-limit redemption; do not log query strings containing bearer material.125- Hash what only needs equality verification; encrypt only what the application must recover.126 With reversible data, `Cipher.getInstance("AES")` delegates mode and padding to the provider.127 Prefer a platform/KMS envelope or a reviewed `AES/GCM/NoPadding`/ChaCha20-Poly1305 facility;128 authenticate tenant, record id, schema and key version as AAD where those fields must not be129 swappable. Nonce uniqueness is per key, and decrypt must release no plaintext before tag130 verification. Store algorithm, key version, nonce and ciphertext/tag so rotation is possible;131 never store a plaintext data-encryption key beside the ciphertext it protects. An encrypted132 (wrapped) data key may accompany the ciphertext when its wrapping key is separately protected.133- A record component or Lombok `@Data` field holding a secret is in `toString()` by134 construction, and `log.info("processing {}", request)` is then a leak nobody wrote.135- Two moves make code worse. **Encrypting what only needs hashing** ("so we can support136 password recovery") trades away the property that mattered — a dump yields no passwords —137 for a feature that is itself a defect, and adds a key needing rotation and custody. **The138 custom crypto wrapper**, `CryptoUtils.hash(String)` "for flexibility", has a signature too139 narrow for Argon2's parameters and drops the algorithm identifier the `{id}` format carries140 per hash, destroying the migration path it was built for — that, not implementing AES, is141 rolling your own crypto.142143## Failure modes and production evidence144145| Symptom | Distinguish with | Likely remediation |146| ---------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |147| Login latency or CPU jumps after a KDF change | KDF duration histogram by encoded algorithm id; auth concurrency and CPU saturation | Bound authentication concurrency, benchmark on production-class hardware, then tune parameters without dropping below the binding floor |148| Known users and unknown users have separable latency | Distributions, not one stopwatch sample; include warm/cold cache paths | Dummy hash with current parameters, common response path and rate limiting; remove existence-specific downstream work |149| Cross-tenant mutation despite role checks | Audit subject, tenant, resource owner and write predicate; enumerate every caller | Derive subject from trusted context and include owner/tenant/version in the transactional write condition |150| Reset link works twice or after replacement | Concurrent redemption and replay tests against the real datastore | Digest-at-rest, expiry and one atomic consume/update; invalidate older outstanding tokens intentionally |151| Secret appears after an exception | Structured-log and error-contract tests with canary secrets | Secret-free value types/messages, allowlisted error mapping and encoder-side redaction as a backstop |152153Do not benchmark password verification with JMH alone and call the capacity question solved.154Measure the primitive to choose parameters, then load-test the bounded authentication path:155arrival bursts, dummy-hash misses, rehash-on-login, datastore latency and rate limiting determine156whether an attacker can turn the KDF into a CPU or memory-exhaustion endpoint.157158## Deliverable159160For each actionable finding, return its source location, reachable attacker and consequence,161proposed adjustment, and the test that would verify it. Separate observed behavior from static162inference; grep matches alone do not prove exploitability. Report tests actually run and gaps.163164## References165166- [Password storage](references/password-storage.md) — OWASP parameter tables, the167 Argon2id-versus-bcrypt disagreement and its JVM complication, Spring Security encoder facts,168 peppering, randomness, `char[]` versus `String`. Read at step 2, and before designing a type169 that holds a credential.170- [Password policy](references/password-policy.md) — NIST 800-63B-4 and ASVS 5.0 requirement171 text side by side. Read when setting registration or change-password rules.172- [Before and after](references/before-after.md) — the non-constant-time comparison in Urma &173 Warburton's Twootr chapter and its fix, and authorisation moved into the domain. Steps 2, 3.174- [Review prompts and verification](references/review.md) — the failure catalogue as175 questions and grep patterns, and how to tell the change improved something. Read at step 6.