CWE-347: Improper Verification of Cryptographic Signature - C
Overview
.NET applications verify JWTs through System.IdentityModel.Tokens.Jwt (JwtSecurityTokenHandler) or the newer Microsoft.IdentityModel.JsonWebTokens (JsonWebTokenHandler), both configured with a shared Microsoft.IdentityModel.Tokens.TokenValidationParameters object. Two configuration mistakes account for most CWE-347 findings in .NET codebases: leaving TokenValidationParameters.ValidAlgorithms unset, which lets the token's own header pick any algorithm the configured key material can satisfy, and resolving signing keys from somewhere the caller can influence, which lets the token choose the key it is checked against.
Be precise about what the first of those still costs you, because the library has moved. Measured on Microsoft.IdentityModel.Tokens 8.22, an RsaSecurityKey never verifies an HMAC signature: the textbook forgery - take a legitimate RS256 token, re-sign it as HS256 using the server's RSA public key as the HMAC secret - is refused whether or not ValidAlgorithms is set, and an alg: none token is refused by the RequireSignedTokens default. Which exception reports the refusal depends on the token's kid: with no kid it is SecurityTokenSignatureKeyNotFoundException (IDX10517), and with a kid naming the RSA key it is SecurityTokenInvalidSignatureException (IDX10511, listing the key it tried) - the same outcome under two names, which matters when a test asserts on the type. What an unset ValidAlgorithms leaves open is every other algorithm the same key satisfies: an RS512, RS384 or PS256 token verifies against a deployment that only ever issues RS256, so "the signature verified" stops being the same statement as "this token was issued the way we issue tokens". It also leaves a mixed key collection - RSA keys and HMAC secrets together, the normal state during an algorithm migration - free to check an HS256 token against the HMAC entry.
A second, less common source of CWE-347 findings is manual XML digital signature verification with System.Security.Cryptography.Xml.SignedXml. Calling CheckSignature() proves that some element in the document was signed by some key embedded in or referenced by the signature - it does not prove that the element your business logic then reads is the one that was validated. This is the basis of XML Signature Wrapping (XSW) attacks.
The safe replacements are: pin ValidAlgorithms to an explicit, hardcoded array; resolve signing keys only from IssuerSigningKey/IssuerSigningKeys or a resolver backed by a trusted server-side JWKS cache; and, for raw signature or HMAC comparisons (webhook payloads, custom protocols), use CryptographicOperations.FixedTimeEquals() instead of ==, SequenceEqual(), or Array.Equals().
Common Vulnerable Patterns
Missing Algorithm Allowlist on TokenValidationParameters
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
// VULNERABLE - no ValidAlgorithms restriction; the token header picks the algorithm
var validationParameters = new TokenValidationParameters
{
IssuerSigningKey = rsaSecurityKey,
ValidateIssuer = true,
ValidIssuer = "https://issuer.example.com",
ValidateAudience = true,
ValidAudience = "my-api",
};
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(token, validationParameters, out _);
// Attack: take a legitimate token, change the header to {"alg":"RS512"}, and
// re-sign it with the same key. Without ValidAlgorithms the handler accepts it,
// because rsaSecurityKey satisfies RS256, RS384, RS512, PS256, PS384 and PS512
// alike - the header, not the deployment, decides which one was used.
Why this is vulnerable: IssuerSigningKey tells the handler what key material is available; it does not say which of the algorithms that key material supports are acceptable. On Microsoft.IdentityModel.Tokens 8.22 the handler will not use an RsaSecurityKey for an HMAC algorithm, so the textbook RS256-as-HS256 forgery no longer works - but every other RSA algorithm is still reachable from the header, and a token that arrives under a kid you rotated to a different algorithm is accepted on the strength of a signature nobody asked for. The gap widens sharply if IssuerSigningKeys holds more than one key type: an HMAC secret kept in the collection during a migration will verify an HS256 token, which is the algorithm-confusion attack in its surviving form. Measured on 8.22, that token is accepted when its kid names the HMAC entry and when it carries no kid at all; only a kid naming the RSA key makes the handler try that key alone and refuse.
Resolving the Signing Key From Something the Caller Controls
// VULNERABLE - the resolver returns whatever key the token's kid points at in
// an untrusted source, so the token selects the key it is verified against
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true, // does NOT constrain where the key came from
IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
{
// fetches a JWKS from a URL in the token, or reads an embedded jwk/x5c
return ResolveKeyFromUntrustedSource(kid);
},
};
Why this is vulnerable: trust comes from where the key was obtained, and the only settings that establish that are IssuerSigningKey, IssuerSigningKeys, and a resolver whose lookup table the application populated itself. ValidateIssuerSigningKey does not do this job despite reading as though it does: measured on 8.22, a resolver returning an attacker's key produces an accepted token with the flag set to true and with it set to false, identically. What the flag actually gates is validation of the key - for an X509SecurityKey it checks the certificate's validity period, so an expired signing certificate is refused with IDX10249 when it is on and accepted when it is off. It is worth turning on (it defaults to false), but a page, a reviewer or a scanner rule that treats it as the untrusted-key control is looking at the wrong setting.
Trusting the Token's Own kid, jku or x5u to Locate the Key
// VULNERABLE - the header names a URL, and the application fetches from it
var validationParameters = new TokenValidationParameters
{
IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
{
var header = new JwtSecurityToken(token).Header;
var jwksUri = header["jku"]?.ToString(); // attacker-supplied
return FetchJwks(jwksUri).GetKeysByKeyId(kid); // fetched from their server
},
};
Why this is vulnerable: kid, jku, x5u, jwk and x5c are unauthenticated header parameters - they are read before any signature is checked, and nothing in the token binds them to the issuer. Using kid as an index into a table the application already holds is safe; using any of them to decide where the key material comes from hands key selection to the sender, who can point it at a key pair they generated. jku and x5u are the sharp cases because the fetch also makes the verifier issue an outbound request to an attacker-chosen URL (CWE-918) on the authentication path.
Trusting SignedXml.CheckSignature() Without Verifying the Signed Reference
using System.Security.Cryptography.Xml;
using System.Xml;
// VULNERABLE - proves a signature is valid, not that it covers the element being trusted
var doc = new XmlDocument { PreserveWhitespace = true };
doc.LoadXml(xmlPayload);
var signedXml = new SignedXml(doc);
var signatureNode = doc.GetElementsByTagName("Signature", SignedXml.XmlDsigNamespaceUrl)[0];
signedXml.LoadXml((XmlElement)signatureNode);
if (signedXml.CheckSignature())
{
// the application reads a DIFFERENT element by tag name, not the one
// referenced by the signature's <Reference URI="..."> - an attacker can
// insert a second, unsigned element with the same tag name elsewhere
// in the document (XML Signature Wrapping) and have it processed here
var amount = doc.GetElementsByTagName("Amount")[0].InnerText;
ProcessPayment(amount);
}
Why this is vulnerable: CheckSignature() with no arguments validates cryptographic integrity for whatever the <Reference URI> in the signature points to, using whatever key is embedded in <KeyInfo> (if present) or supplied separately. It does not confirm that the element the application later reads is that same referenced element, and it does not confirm the key is one the application trusts. An attacker who can inject a second element into the document can move or duplicate content outside the signed subtree while leaving the original signature intact.
Secure Patterns
Pin the Algorithm Allowlist and Validate All Standard Claims
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
// SECURE - algorithm is pinned; the token header cannot select RS512 or PS256
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = rsaSecurityKey,
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
RequireSignedTokens = true,
ValidateIssuer = true,
ValidIssuer = "https://issuer.example.com",
ValidateAudience = true,
ValidAudience = "my-api",
ValidateLifetime = true,
};
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(token, validationParameters, out _);
Why this works: each of these settings does a different job, and none substitutes for another.
ValidAlgorithmsis checked independently of which key material is configured, so a header naming anything butRS256is rejected before signature verification runs. Verified on 8.22: the RS512 token that the unpinned configuration above accepts is refused here.IssuerSigningKeyis what makes the key trusted, because the application supplied it. A token signed by any other key is refused - measured,SecurityTokenInvalidSignatureException(IDX10511) when itskidnames the configured key,SecurityTokenSignatureKeyNotFoundExceptionwhen it carries none. What a single static key does not do is checkkidat all:TryAllIssuerSigningKeysdefaults totrue, so a genuine signature under an unknownkidis accepted. Refusing an unknownkidis the resolver pattern's job, below.ValidateIssuerSigningKey = trueadds validation of the key itself - for anX509SecurityKey, that the certificate has not expired. It is off by default, so setting it is worth doing; it is not what stops an untrusted key.RequireSignedTokens = truerejects unsigned tokens outright. It is already the default; stating it keeps a later edit from turning it off by accident.
Resolve Keys by kid From a Trusted Keystore, Never From the Token
// SECURE - IssuerSigningKeyResolver only consults a server-side JWKS cache,
// never data supplied by the token itself (no jwk/x5c header trust)
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
trustedJwksCache.GetSigningKeysByKeyId(kid), // throws if kid is unknown
};
Why this works: The resolver's only input that can vary per request is kid, used purely as a lookup key into a keystore the application populated ahead of time from a trusted JWKS endpoint. Nothing about the returned key's type or algorithm is influenced by attacker-controlled token content, so there is no path for a token to select a key that was never meant to verify it.
Constant-Time Comparison for Raw Signatures and HMACs
using System.Security.Cryptography;
// SECURE - webhook HMAC-SHA256 verification with constant-time comparison
static bool VerifyWebhookSignature(byte[] requestBody, string signatureHeader, byte[] webhookSecret)
{
byte[] expected = HMACSHA256.HashData(webhookSecret, requestBody);
byte[] provided;
try
{
provided = Convert.FromHexString(signatureHeader);
}
catch (FormatException)
{
// Convert.FromHexString throws on an odd-length or non-hex string, and
// the signature header is attacker-controlled. Without this the method
// answers 500 to a malformed header instead of rejecting it.
return false;
}
return CryptographicOperations.FixedTimeEquals(expected, provided);
}
Why this works: CryptographicOperations.FixedTimeEquals() (.NET Core 2.1+) is constant-time for equal-length spans: it compares every byte before returning, so the time does not depend on where the first mismatch falls. Measured on .NET 10 over 32-byte digests, an identical pair, a first-byte mismatch and a last-byte mismatch all take about 200 ns.
What it does not hide is the length. Different lengths return false immediately - 8 ns for 16 bytes against 32, an order of magnitude under any equal-length call - so a caller who can vary the length still learns the expected one. That is the documented contract rather than a defect, and for an HMAC it costs nothing, because the digest length is fixed by the algorithm and is not a secret. It matters only where the length of the compared value is itself sensitive; there, compare fixed-size digests of the two values instead. CWE-385 has the length-mismatch behaviour of the equivalent primitive in every language.
== on byte arrays compares references rather than contents, so it is wrong here rather than merely slow. SequenceEqual() and a hand-written loop both return at the first difference and so both leak how much of the value matched, but not to the same degree: SequenceEqual() is vectorised and compares a block at a time, while a hand-written loop really does leak a byte at a time. CWE-208 has the measured spread for each, and why the fix is the same either way.
The try/catch is the part that is easy to leave out, and leaving it out is not cosmetic. Measured on .NET 10, Convert.FromHexString throws FormatException for a 63-character header and for a 64-character one containing a non-hex character - both trivially sendable, both reaching the comparison only through the attacker's input, so every legitimate-traffic test passes and the endpoint answers a server error to the exact request it was hardened against.
Bind XML Signature Verification to the Trusted Element and Key
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography.Xml;
using System.Xml;
// SECURE - check the signer's certificate, verify against it, then confirm the
// reference covers the exact element the application is about to trust
var doc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null }; // disable external entity resolution
doc.LoadXml(xmlPayload);
// The second parameter of CheckSignature is verifySignatureOnly, and passing
// true means the certificate itself is NOT examined - not the chain, not the
// validity dates, not revocation. Because trustedSigningCertificate is pinned
// by the application rather than taken from the document, the chain is not the
// relevant question, but the dates still are, so check them here.
var now = DateTime.Now;
if (now < trustedSigningCertificate.NotBefore || now > trustedSigningCertificate.NotAfter)
{
throw new SecurityException("Signing certificate is outside its validity period");
}
var signedXml = new SignedXml(doc);
var signatureNode = (XmlElement)doc.GetElementsByTagName("Signature", SignedXml.XmlDsigNamespaceUrl)[0];
signedXml.LoadXml(signatureNode);
// verify with the pinned certificate, not one embedded in KeyInfo
bool cryptoValid = signedXml.CheckSignature(trustedSigningCertificate, verifySignatureOnly: true);
var amountNodes = doc.GetElementsByTagName("Amount");
if (!cryptoValid || amountNodes.Count != 1)
{
throw new SecurityException("Signature invalid or ambiguous element count");
}
var reference = (Reference)signedXml.SignedInfo.References[0];
var referencedId = reference.Uri.TrimStart('#');
if (amountNodes[0].Attributes?["Id"]?.Value != referencedId)
{
throw new SecurityException("Signed reference does not cover the trusted element");
}
var amount = amountNodes[0].InnerText;
Why this works: the key used for verification is the one the application supplies, so an attacker who rewrites <KeyInfo> changes nothing about which key is consulted. Rejecting a document that has more than one element with the trusted tag name, and confirming the <Reference URI> actually points at the element identifier the application is about to read, closes the wrapping gap: an injected duplicate element either breaks the uniqueness check or fails the reference-match check, even though the original signature still cryptographically verifies. Measured against a document signed the ordinary way and the same document with a second <Amount Id="evil"> spliced in, the first is accepted and the second is refused on the count check.
Read the second parameter before copying this. CheckSignature(X509Certificate2, bool) takes verifySignatureOnly, not "also validate the certificate", and the two readings are opposites. Measured on .NET 10: CheckSignature(cert, true) returns true for a self-signed certificate and for one that expired a month ago, because in that mode nothing about the certificate is looked at; CheckSignature(cert, false) returns false for both, because it then runs a full X509Chain build that a self-signed certificate cannot pass. Which value you want follows from where the certificate came from:
- Pinned certificate, loaded from your own configuration or key vault, as above:
trueis correct - the certificate is the trust anchor, and asking the machine trust store about it would refuse a perfectly good internal signer. Check the validity dates yourself, as the example does, because that is the one certificate property that still matters andtrueskips it. - Certificate taken from the document and expected to chain to a CA you trust:
false, or build anX509Chainyourself with your CA inChainPolicy.CustomTrustStoreandTrustMode = X509ChainTrustMode.CustomRootTrust. Do not passtruehere - it reduces the check to "this document was signed by whoever sent it".
Framework-Specific Guidance
ASP.NET Core JWT Bearer Authentication
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
trustedJwksCache.GetSigningKeysByKeyId(kid),
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateLifetime = true,
};
});
Why this works: AddJwtBearer runs every incoming request's token through the same TokenValidationParameters object, so setting ValidAlgorithms and a trusted IssuerSigningKeyResolver here fixes the check application-wide instead of relying on every controller action to validate tokens correctly on its own.
Considerations
This is one of the few findings that is almost never a false positive. For most weaknesses the first question is whether the value is security-relevant; here, if a signature is being checked at all, something is trusting the result. The narrow exception is data that never crossed a trust boundary - a token your own process minted, held in memory, and verified moments later. If the token arrived over the network, the check matters.
Decide where verification happens, and whether once is enough. A gateway that verifies tokens before forwarding lets backend services skip the work, which is efficient and fine until one service becomes reachable another way - an internal caller, a service mesh retry, a debugging port. Verifying again in the service costs little and does not depend on network topology staying as drawn. If you do rely on the gateway, make it impossible to bypass rather than merely inconvenient.
Symmetric algorithms give every verifier the power to mint. HS256 uses one shared secret, so any service holding it to check tokens can also issue them. With three services and one secret you have three places a forged administrator token can come from. RS256 and EdDSA split that: the issuer holds the private key, verifiers hold only the public one. If more than one service verifies, that separation is worth the extra key management.
Check the clock skew default before setting it. TokenValidationParameters
allows five minutes of skew out of the box, so a token can be accepted for five
minutes after it expires. That default surprises people who set a short
lifetime and assume it is enforced exactly. Set ClockSkew deliberately -
TimeSpan.Zero if your clocks are synchronised, a small explicit value if not.
Key rotation needs a cache policy decided in advance. Resolving keys by
kid from a JWKS endpoint means an outbound fetch on the verification path.
Cache too briefly and every request becomes a network call, so an issuer outage
takes your authentication down with it; cache too long and a rotated-away key
stays trusted. Cache by kid with a refresh on unknown values, plus a floor on
how often that refresh can fire, so an attacker cannot drive fetches by sending
tokens with random kid values.
Expiry is not revocation. Signature verification proves a token was issued and unmodified; it says nothing about whether the account was disabled a minute ago. Short lifetimes narrow that window and cost a refresh round trip; a revocation list closes it and costs a lookup on every request. Which you need depends on how quickly access must actually stop - "immediately" and "within fifteen minutes" are different systems.
Testing
- Normal: a legitimately issued RS256 token from the real issuer validates and its claims are usable, and a correctly HMAC-signed webhook payload returns 200.
- Boundary: a token that is correctly signed but expired is rejected with
SecurityTokenExpiredException, and one signed under akidnot present in the trusted keystore is rejected by the resolver refusing the lookup. Run the second against the resolver configuration and not a staticIssuerSigningKey: with a static key,TryAllIssuerSigningKeysdefaults totrueand a genuine signature under an unknownkidis accepted - measured on 8.22. - Boundary - malformed signature header: send a 63-character signature header and a 64-character one containing
z. Both must produce the same rejection as a wrong-but-well-formed signature, not a 500. This is the assertion that catches an unguardedConvert.FromHexString. - Malicious - cross-algorithm: re-sign a valid RS256 token as RS512 with the same key.
ValidateToken()must throwSecurityTokenInvalidSignatureException. WithoutValidAlgorithmsthis token is accepted, so it is the test that actually exercises the fix - the RS256-as-HS256 forgery below is refused by the library either way and proves nothing about your configuration. - Malicious - algorithm confusion: re-sign a valid RS256 token as HS256 using the server's known RSA public key (SPKI bytes or PEM text) as the HMAC secret.
ValidateToken()must throw; onMicrosoft.IdentityModel.Tokens8.22 it throwsSecurityTokenSignatureKeyNotFoundExceptionwhen the forged token carries nokidandSecurityTokenInvalidSignatureExceptionwhen itskidnames your RSA key - refused either way, so assert on the throw rather than on one type. If yourIssuerSigningKeyscollection also holds an HMAC secret, run this test with that secret instead, with the token'skidnaming that entry or absent - it is the version that still succeeds withoutValidAlgorithms. - Malicious - alg=none: submit a token with header
{"alg":"none"}and no signature segment; verification must fail, not succeed with an empty signature. - Malicious - untrusted key resolver: point the resolver at a key the application never configured and submit a token signed with it. Rejection must come from the resolver refusing an unknown
kid, not fromValidateIssuerSigningKey- flip that flag tofalseand confirm the verdict does not change, which is what tells you the trust decision is where you think it is. - Malicious - XML wrapping: insert a second, unsigned element with the same tag/ID pattern into a validly signed XML document; verification must either fail or the application must read the originally signed element, not the injected one.
- Malicious - expired signer: sign an XML document with an expired certificate and verify it. It must be refused.
CheckSignature(cert, true)returnstruefor an expired certificate, so this assertion fails on any implementation that leaves the certificate unchecked.
Common Pitfalls
- Reading
ValidateIssuerSigningKey = trueas "only trusted keys are used": it is not that check, and setting it does not narrow where a key may come from. Measured on 8.22, a resolver returning an attacker's key is accepted with the flag on exactly as it is with the flag off; what changes is whether anX509SecurityKeywhose certificate has expired is refused. Trust is established byIssuerSigningKey,IssuerSigningKeys, or a resolver that only looks things up in a table you populated. Set the flag anyway - it defaults tofalse- but do not close a finding on it. - Setting
ValidateIssuerSigningKey = truebut leavingValidAlgorithmsunset: if the configuredIssuerSigningKey/IssuerSigningKeyscollection contains both RSA keys and HMAC secret keys (for example, during a migration between algorithms), the handler will verify an HS256 token against one of the HMAC keys when the token'skidnames that key or is absent - measured, and the same configuration withValidAlgorithms = { RsaSha256 }refuses it. Even with a single RSA key, the header can still select RS384, RS512 or the PSS variants. PinValidAlgorithmsregardless of how narrow the key set looks today. - Calling
SignedXml.CheckSignature()with no arguments: this overload trusts whatever<KeyInfo>is embedded in the document, meaning a self-signed or attacker-supplied certificate can make an attacker-crafted document validate. Always call an overload that takes a certificate or key you control. - Passing
trueas the second argument toCheckSignature(cert, ...)on the assumption it means "check the certificate too": the parameter isverifySignatureOnly, sotrueis the value that skips every certificate check there is. Whethertrueorfalseis right depends on whether the certificate is pinned or has to chain to a CA - see the secure pattern above - but the reading that produces the mistake is treating it as a strictness switch, and passingverifySignatureOnly:by name at the call site is what keeps the next reader from making it. - Leaving
Convert.FromHexString()unguarded when decoding an attacker-supplied signature header: it throwsFormatExceptionon an odd-length or non-hex string, so a malformed header produces a 500 rather than a rejection.CryptographicOperations.FixedTimeEquals()handles a length mismatch correctly; the decode in front of it does not. - Fixing the algorithm allowlist but leaving a legacy custom verification path: codebases migrating from hand-rolled
RSA.VerifyData()/HMACSHA256checks toTokenValidationParameterssometimes leave an older endpoint or background worker still calling the old code directly. Search forVerifyData(,VerifyHash(, and manualHMACSHA256usage in addition toTokenValidationParameters.
Dependencies and Installation
Microsoft.IdentityModel.TokensandSystem.IdentityModel.Tokens.Jwt(or the newer, lighterMicrosoft.IdentityModel.JsonWebTokens+JsonWebTokenHandler) - keep at a current maintained version. Stay at or above 5.7.0 on the 5.x line, 6.34.0 on 6.x, or 7.1.2 on 7.x: versions below those are vulnerable to CVE-2024-21319, a denial of service where a crafted JWE token with a high compression ratio drives excessive memory and CPU use during decompression.System.Security.Cryptography.Xmlships with the .NET runtime; no separate package, but confirm you are on a currently supported .NET version for the latest XML processing security fixes.
Migration Considerations
Tightening ValidAlgorithms to a single algorithm will reject any previously accepted token signed with an algorithm you are removing from the allowlist (for example, dropping HS256 support after finding it was unintentionally accepted). Coordinate the change with a token-issuer update and expect currently active sessions signed under the old configuration to need re-authentication once the fix ships.
Additional Resources
- CWE-347: Improper Verification of Cryptographic Signature
- GHSA-59j7-ghrg-fj52 (CVE-2024-21319) - the JWE decompression denial of service behind the 5.7.0 / 6.34.0 / 7.1.2 version floors above
- Microsoft.IdentityModel.Tokens.TokenValidationParameters
- OWASP JSON Web Token Cheat Sheet
- OWASP Top 10 2025 A04: Cryptographic Failures