
Preventing JWT Verification Mistakes in Rust: Safely Validating Signed Tokens
Why JWT verification fails in real systems
JWT bugs usually come from treating parsing as verification. A token can decode successfully and still be invalid for your application. Common mistakes include:
- Accepting whatever algorithm the token header declares
- Skipping issuer or audience checks
- Ignoring expiration or not-before claims
- Trusting claims before signature verification
- Using a shared secret across unrelated services
- Returning detailed validation errors to attackers
A secure verifier must enforce policy, not just syntax.
The secure verification model
A robust JWT validation pipeline should follow this order:
- Parse the token structure
- Verify the signature with an expected algorithm and key
- Validate standard claims such as
exp,nbf,iat,iss, andaud - Deserialize claims into a typed Rust structure
- Use the claims only after validation succeeds
The important principle is that the token header and payload are attacker-controlled until verification completes.
Choosing the right crate and key strategy
The jsonwebtoken crate is widely used in Rust applications. It supports HMAC, RSA, and ECDSA verification, and it lets you configure validation rules explicitly.
Your key strategy should match your deployment model:
| Scenario | Recommended approach | Notes |
|---|---|---|
| Single service or small internal system | HMAC with a strong secret | Keep the secret outside source control |
| Multiple services verifying tokens | Asymmetric signing, such as RS256 | Verification keys can be distributed safely |
| Rotating keys or third-party identity providers | Key IDs (kid) plus a controlled key lookup | Never fetch keys from arbitrary URLs |
For most distributed systems, asymmetric signing is safer because verifiers do not need the private signing key.
Defining typed claims
Typed claims make it harder to misuse token data. Instead of working with a generic map, define a Rust struct that matches the expected payload.
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
struct Claims {
sub: String,
iss: String,
aud: String,
exp: usize,
nbf: Option<usize>,
scope: Option<String>,
}This structure gives you compile-time clarity and makes it easier to enforce application-specific rules after verification.
Verifying a token correctly
The following example shows a safe verification flow using an HMAC secret. It explicitly sets the expected algorithm and validates issuer and audience.
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation, errors::Error};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
struct Claims {
sub: String,
iss: String,
aud: String,
exp: usize,
nbf: Option<usize>,
scope: Option<String>,
}
fn verify_jwt(token: &str, secret: &[u8]) -> Result<Claims, Error> {
let mut validation = Validation::new(Algorithm::HS256);
validation.set_issuer(&["https://auth.example.com"]);
validation.set_audience(&["my-api"]);
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret),
&validation,
)?;
Ok(token_data.claims)
}This code does three important things:
- It pins the algorithm to
HS256 - It checks the issuer and audience
- It returns claims only after verification succeeds
Do not accept the algorithm from the token header as a policy decision. The verifier should decide which algorithm is allowed.
Avoiding algorithm confusion
Algorithm confusion happens when a verifier accepts a token signed with a different algorithm than intended. For example, a system expecting RSA verification might accidentally treat a public key as an HMAC secret if the code is too flexible.
To prevent this:
- Hardcode or configure the expected algorithm per token type
- Use separate keys for different algorithms
- Reject tokens whose header
algdoes not match the verifier’s policy - Never reuse a public key as an HMAC secret
A good rule is: the token may describe itself, but it must not choose how it is verified.
Validating time-based claims
Time claims are often mishandled because developers assume the library will do everything automatically. In practice, you should understand what is being checked and whether clock skew is acceptable.
exp
The token must not be expired.
nbf
The token must not be used before the stated time.
iat
The token should not be unreasonably far in the future.
If your system has clock skew between services, allow a small leeway, but keep it tight.
use jsonwebtoken::{Algorithm, Validation};
let mut validation = Validation::new(Algorithm::RS256);
validation.leeway = 30; // seconds
validation.validate_exp = true;
validation.validate_nbf = true;Use leeway sparingly. Large skew windows weaken token lifetime guarantees.
Enforcing issuer and audience
Issuer and audience checks are essential in multi-service environments. Without them, a token issued for one application might be accepted by another.
issidentifies who issued the tokenaudidentifies who the token is intended for
Always compare these values against exact expected strings or a tightly controlled allowlist. Do not use substring checks or prefix matching.
Bad pattern:
if claims.iss.contains("example.com") {
// unsafe: too broad
}Better pattern:
if claims.iss == "https://auth.example.com" {
// exact match
}This matters because attacker-controlled values can be crafted to satisfy loose comparisons.
Handling key rotation safely
Production systems often rotate signing keys. The usual JWT mechanism for this is the kid header, which identifies the key used to sign the token.
Rotation introduces a new risk: if your verifier accepts arbitrary kid values and uses them to query a remote source, an attacker may influence key selection or trigger unwanted network access.
Safer practices:
- Maintain a local allowlist of trusted key IDs
- Map
kidto keys in memory or from a trusted configuration store - Reject unknown
kidvalues - Cache keys with explicit expiration and refresh logic
- Do not construct file paths or URLs directly from
kid
A controlled lookup table is much safer than dynamic retrieval based on attacker input.
Separating authentication from authorization
A verified token only proves that the claims were signed by a trusted issuer. It does not automatically grant access to every operation.
Use JWT claims to establish identity and coarse-grained roles, then apply application-specific authorization checks separately.
For example:
subidentifies the user or service accountscopemay indicate allowed API categories- Your business logic decides whether that scope is sufficient for a specific endpoint
This separation prevents over-trusting token contents and keeps authorization logic explicit.
Common mistakes to avoid
| Mistake | Why it is dangerous | Safer alternative |
|---|---|---|
| Decoding without verifying | Attacker-controlled claims are treated as trusted | Always verify signature first |
| Accepting any algorithm | Enables algorithm confusion | Pin the expected algorithm |
Skipping iss and aud checks | Tokens may be valid in the wrong context | Enforce exact issuer and audience |
| Using weak secrets | HMAC tokens can be forged | Use high-entropy secrets or asymmetric keys |
| Logging full tokens | Sensitive data may leak into logs | Log only a short token fingerprint |
Trusting kid blindly | Key selection can be manipulated | Use a local trusted key map |
Designing a safer verification API
In larger codebases, wrap JWT verification in a small internal API so that developers cannot accidentally bypass checks.
A good wrapper should:
- Accept only the raw token string
- Hide key lookup details
- Enforce algorithm and claim validation internally
- Return a typed claims object on success
- Return a generic error on failure
This keeps security policy centralized and reduces the chance that one endpoint uses a weaker validation path than another.
pub fn authenticate_request(token: &str) -> Result<Claims, AuthError> {
let claims = verify_jwt(token, b"super-long-random-secret")?;
Ok(claims)
}In a real system, the secret should come from secure configuration, not a hardcoded literal. The example shows the shape of the API, not the storage strategy.
Testing your verifier
Security-sensitive code needs tests that prove invalid tokens are rejected.
Test cases should include:
- Expired token
- Token with wrong issuer
- Token with wrong audience
- Token signed with the wrong key
- Token using a disallowed algorithm
- Token with malformed claims
- Token with
nbfin the future
A useful pattern is to create a valid token fixture and then mutate one field at a time. This helps confirm that each validation rule is active.
Also test your error handling path. Attackers often probe differences in error messages to infer whether a token was close to valid.
Operational best practices
JWT verification is not just a code problem. Operational choices matter too.
- Store signing secrets in a secret manager or environment variable injected at deploy time
- Rotate keys on a schedule and support overlap during migration
- Monitor verification failures for spikes that may indicate abuse
- Keep token lifetimes short, especially for browser-facing sessions
- Prefer refresh tokens or re-authentication for long-lived sessions
- Review any third-party identity provider integration carefully
Short token lifetimes reduce the impact of token theft and make revocation more practical.
Conclusion
Secure JWT handling in Rust depends on explicit verification, not convenience. The safest approach is to pin the algorithm, validate issuer and audience, enforce time-based claims, and keep key selection under your control. Typed claims and a narrow verification wrapper help prevent accidental misuse across your codebase.
If you treat every token as untrusted until verification completes, you eliminate most of the common JWT failures seen in production systems.
