Software engineer reviewing PyJWT expiration checks and required JWT claims at a workstation.

Your JWT Passed Verification. Was an Expiration Required?

Thu, Oct 8, 2026

Suppose an application receives a JWT (signed HS256 with a fixed issuer and audience) that lacks an “exp” claim but nevertheless decodes successfully under PyJWT’s default expiration check. In this case, calling jwt.decode(..., options={"verify_exp": True}) returns the claims with no error. Does “successful decoding” imply the token should be admitted to the resource? The answer depends on the application’s policy. In our scenario, we intend that a valid token must have a present exp timestamp that has not yet passed. However, RFC 7519 itself says the “exp” claim is optional, only dictating that if present, “the JWT MUST NOT be accepted” at or after that time. PyJWT 2.10.1 by default does not require exp unless configured. To avoid gaps between policy and practice, we will compare three decoder configurations against six signed token cases, producing a claim-presence/value-validation matrix.

This controlled experiment (with no real credentials or network calls) enumerates outcomes like "ACCEPT", MissingRequiredClaimError, and ExpiredSignatureError. We note this is an offline simulation: the code is syntax-validated but PyJWT was not actually installed in the commissioning environment, so results are expected values, not measured outputs. We do not claim a production incident or full security audit. Instead, we isolate the question: under what settings does the same token (missing, valid, or expired exp) get accepted or rejected? The distinction is between passing the library decode call and meeting the stricter admission contract. We then derive admission criteria (for the chosen validator profile) and remediation steps. This focuses narrowly on the expiration check in isolation; it does not cover other aspects of authentication, authorization, or token revocation (those remain separate responsibilities).

1. Define the expiration admission contract

For this exercise, the application-specific admission contract is: a JWT is accepted only if its signature, issuer, and audience match configuration and it contains an “exp” claim whose value is in the future. In other words, we require signature, issuer, and audience validity (common API security prerequisites) plus a present expiration timestamp that passes its time check. This is a proposed profile for our particular service and is stricter than the generic JWT format. (By itself, the JWT spec does not mandate exp; it only states that when present the token must not be accepted at or after that time.) In many APIs, including this one, short-lived tokens are a design decision, so we treat exp as required.

We assume signature (HS256), issuer, and audience values are already validated; otherwise the token is invalid regardless of exp. The general API-security foundation (see secure API development foundations for token prerequisites) is that we fix those validation parameters out-of-band. Here we focus on expiration handling only. The policy we will verify is: the decoder must accept a JWT only if exp is present and not expired. If our decoding configurations allow a token without exp, that would violate this contract. In essence, we overlay an “expiration gate” atop the normal signature/claims checks.

2. Freeze the validator configuration and trust inputs

We fix the environment and validation settings upfront. The reference decoder is PyJWT 2.10.1 (per the versioned docs) running on Python 3. The code pins PyJWT at 2.10.1, not as a production suggestion but to reproduce exactly that behavior. The issuer is set to "https://issuer.example/lab" and the expected audience to "urn:refonte:expiry-lab". We use an HS256 symmetric signing key (32 random bytes) and explicitly set algorithms=["HS256"] in each decode call, never trusting the token’s "alg" header. Similarly, issuer and audience are fixed inputs, not derived from incoming data. In all profiles we set verify_signature=True, verify_iss=True, and verify_aud=True so those checks are always enforced. A leeway of 0 seconds is used (strict timing, no clock skew allowed).

These choices do not vary between profiles A, B, C: they differ only in options.verify_exp and options.require. In effect, the underlying trust parameters (key, issuer, audience, algorithms) are independent of the test tokens. We avoid any “algorithm confusion” by fixing HS256 in code, and do not implement discovery or browsing. (For context, the MCP resource and audience boundaries article covers multi-issuer and resource-specific validation, which is outside our scope here.) By locking down these values, any difference in acceptance/rejection must come solely from the expiration checks.

Keep expected trust values independent of received claims

In our synthetic setup, issuer and audience come from configuration and never change per token. For example, even if a test token’s iss claim says "https://evil.example", we expect the decoder to reject it based on our configured "https://issuer.example/lab". This matches best practices: applications should not blindly trust token fields such as "alg", "iss", or "aud" from the token. We also do not use any external services (no network calls, no JWKS, no OPs). The token’s sub is just a harmless string (“synthetic-user”); we do not look up any user or consider scopes here. In short, the same correct key/issuer/audience are held constant, and only the presence/value of exp will drive any differences in behavior.

3. Build the six-token corpus and the complete offline driver

We have prepared a self-contained Python test driver (jwt_exp_lab.py) that generates six tokens and attempts to decode each under three validation profiles. This is shown below (with explanatory comments). When run in an environment with PyJWT 2.10.1, it produces a JSON results file. In our commissioning, PyJWT wasn’t installed, so this code only passed syntax checks and the expected outputs were predetermined. We include it here for reproducibility (and to verify expected structure), but note that any real run would produce actual results.

"""Offline PyJWT claim-presence experiment; proposed, not executed here."""
import importlib.metadata
import json
from pathlib import Path
import platform
import secrets
import sys
import tempfile
import time

class EnvironmentHold(RuntimeError):
    pass

def require(condition, label):
    if not condition:
        raise RuntimeError(label)

def main():
    run_dir = Path(tempfile.mkdtemp(prefix="refonte-jwt-exp-"))
    report = {
        "status": "INCOMPLETE", "scope": "offline decoder experiment",
        "python": sys.version, "platform": platform.platform(), "rows": [],
        "contains_encoded_tokens_or_keys": False,
    }
    exit_code = 1
    try:
        try:
            import jwt
        except ModuleNotFoundError as exc:
            raise EnvironmentHold("PyJWT unavailable") from exc
        version = importlib.metadata.version("PyJWT")
        report["pyjwt"] = version
        if version != "2.10.1":
            raise EnvironmentHold("This reference fixture pins PyJWT 2.10.1")

        # Configuration parameters
        issuer = "https://issuer.example/lab"
        audience = "urn:refonte:expiry-lab"
        signing_key = secrets.token_bytes(32)
        alternate_key = secrets.token_bytes(32)
        while alternate_key == signing_key:
            alternate_key = secrets.token_bytes(32)
        t0 = int(time.time())
        mono_start = time.monotonic()
        report.update({"t0_utc_seconds": t0, "leeway_seconds": 0,
                       "allowed_algorithm": "HS256"})

        # Define cases and profiles
        case_ids = ["valid", "missing_exp", "expired", "wrong_signature",
                    "wrong_issuer", "wrong_audience"]
        profiles = {
            "A_optional_exp": {"verify_exp": True, "require": []},
            "B_required_and_verified": {"verify_exp": True, "require": ["exp"]},
            "C_offline_presence_only": {"verify_exp": False, "require": ["exp"]},
        }
        # Authored oracle of expected outcomes
        expected = {
            "valid": ["ACCEPT", "ACCEPT", "ACCEPT"],
            "missing_exp": [
                "ACCEPT", "MissingRequiredClaimError", "MissingRequiredClaimError"
            ],
            "expired": ["ExpiredSignatureError", "ExpiredSignatureError", "ACCEPT"],
            "wrong_signature": ["InvalidSignatureError"]  3,
            "wrong_issuer": ["InvalidIssuerError"]  3,
            "wrong_audience": ["InvalidAudienceError"]  3,
        }
        require(case_ids == ["valid", "missing_exp", "expired",
                             "wrong_signature", "wrong_issuer", "wrong_audience"],
                "case inventory")
        require(list(profiles) == ["A_optional_exp", "B_required_and_verified",
                                   "C_offline_presence_only"],
                "profile inventory")
        require(set(expected) == set(case_ids), "oracle coverage")

        for case_id in case_ids:
            # Base claims
            claims = {"iss": issuer, "aud": audience, "sub": "synthetic-user"}
            # Attach exp unless missing_exp case
            if case_id != "missing_exp":
                claims["exp"] = t0 - 3600 if case_id == "expired" else t0 + 3600
            # Alter issuer/audience/key for negative tests
            if case_id == "wrong_issuer":
                claims["iss"] = "https://other-issuer.example/lab"
            if case_id == "wrong_audience":
                claims["aud"] = "urn:refonte:other-lab"
            key = alternate_key if case_id == "wrong_signature" else signing_key
            token = jwt.encode(claims, key, algorithm="HS256")

            for profile_index, (profile_id, profile) in enumerate(profiles.items()):
                # Set decode options for this profile
                options = {"verify_signature": True, "verify_iss": True,
                           "verify_aud": True, "verify_iat": True,
                           "verify_nbf": True, *profile}
                row = {"case": case_id, "profile": profile_id,
                       "expected": expected[case_id][profile_index],
                       "has_exp": "exp" in claims, "status": "INCOMPLETE"}
                report["rows"].append(row)

                # Time stamps before/after to check clock stability
                wall_before, mono_before = time.time(), time.monotonic()
                decoded = None
                try:
                    decoded = jwt.decode(token, signing_key, algorithms=["HS256"],
                                         issuer=issuer, audience=audience,
                                         leeway=0, options=options)
                    observed = "ACCEPT"
                except jwt.InvalidTokenError as exc:
                    observed = type(exc).__name__
                finally:
                    wall_after, mono_after = time.time(), time.monotonic()
                    row.update({"wall_before": wall_before,
                                "wall_after": wall_after,
                                "decode_elapsed_seconds": mono_after - mono_before})
                # Check clock drift to rule out accidental flips
                if not (t0 - 60 <= wall_before <= t0 + 60 and
                        t0 - 60 <= wall_after <= t0 + 60 and
                        0 <= mono_after - mono_start <= 60):
                    row["status"] = "ENVIRONMENT_HOLD"
                    raise EnvironmentHold("Clock window invalidates this run")
                row["observed"] = observed
                row["status"] = (
                    "MATCH" if observed == row["expected"] else "MISMATCH"
                )
                if observed == "ACCEPT":
                    require(decoded == claims, "accepted synthetic claims differ")
                require(row["status"] == "MATCH", "unexpected decoder outcome")

        require(len(report["rows"]) == 18, "incomplete matrix")
        require(len({(r["case"], r["profile"]) for r in report["rows"]}) == 18,
                "duplicate or missing case/profile")
        require(all(r["status"] == "MATCH" for r in report["rows"]),
                "matrix failed")
        report["status"] = "PASS_REFERENCE_MATRIX"
        report["meaning"] = (
            "Expected decoder outcomes only; no production security approval"
        )
        exit_code = 0

    except EnvironmentHold as exc:
        report["status"] = "ENVIRONMENT_HOLD"
        report["failure_class"] = type(exc).__name__
        exit_code = 2
    except BaseException as exc:
        report["status"] = "FAIL"
        report["failure_class"] = type(exc).__name__
        exit_code = 1
    finally:
        output = run_dir / "results.json"
        with output.open("x", encoding="utf-8") as handle:
            json.dump(report, handle, ensure_ascii=False, indent=2)
            handle.write("\n")
        print(json.dumps({"status": report["status"], "report": str(output)}))
    return exit_code

if name == "__main__":
    raise SystemExit(main())

This script iterates each case/profile combination and asserts the observed outcome matches the expected table. If all 18 row results match, status becomes "PASS_REFERENCE_MATRIX" (meaning the library behavior exactly matched our expectation). Important: PASS_REFERENCE_MATRIX only means the test ran as expected, not that every profile is secure. In fact, by design this matrix shows that Profile A (optional-exp) and Profile C (presence-only) should not meet our contract: both are expected to accept a disallowed token (A accepts the missing-exp case, C accepts the expired case). A green matrix here merely indicates the experiment behaved consistently; it does not imply an approval of every profile. The script also records environment blockers, such as unavailable or mismatched PyJWT and an invalid clock window, as ENVIRONMENT_HOLD, and never includes tokens or keys in its JSON output (contains_encoded_tokens_or_keys=False).

4. Reproduce acceptance when exp is missing

Profile A (verify_exp=True, require=[]) represents a lax policy: we check expiration only if it exists, but don’t enforce its presence. Decoding the missing_exp token under Profile A is expected to succeed ("ACCEPT"). This follows from PyJWT’s documented behavior: expiration is checked only when exp is present; require must be configured to enforce its presence. In other words, with no exp claim, PyJWT does nothing special and returns the other claims. Our test expects observed = "ACCEPT" for Profile A on missing_exp. This outcome is a policy gap: the decoder call would pass, but the token would not meet our intended admission requirement. To avoid confusion: accepting here does not mean signature verification failed or PyJWT violated the JWT spec; it means the application’s validator configuration did not check for presence.

Example: If we ran jwt.decode(token, key, algorithms=["HS256"], options={"verify_exp": True}, issuer=..., audience=...) on a token without exp, PyJWT would simply return the payload. The library won’t raise an ExpiredSignatureError or any error, because there is no expiration to compare. This confirms that Profile A admits a token that should have been rejected by policy. (By contrast, if we had verify_exp=False, PyJWT would also accept it, but here verify_exp=True just didn’t matter due to missing field.)

5. Require the claim and verify its value

Profile B (verify_exp=True, require=["exp"]) enforces both presence and validity of exp. Now the missing_exp token will trigger an exception: PyJWT raises MissingRequiredClaimError when a required claim is absent. Indeed, our expected outcome is "MissingRequiredClaimError" for Profile B on missing_exp. Similarly, for the expired token (which has exp = past time), Profile B will raise ExpiredSignatureError, because verify_exp is True. The valid token (future exp) should decode ("ACCEPT"). Thus Profile B cleanly separates the two failure modes: missing claim vs expired value.

It’s important to distinguish these checks. As the docs note, require=["exp"] only ensures the exp field exists; it does not itself check that exp is a future timestamp. That still depends on verify_exp. In our test cases, we only use valid integers. We do not explore malformed or fractional values here. The script shows that requiring exp does not automatically validate its content beyond existence. The integer time is checked by verify_exp which compares to the current UTC time. The key point: presence checking and time checking are separate steps. Our matrix logs each reason class separately (MissingRequiredClaimError vs ExpiredSignatureError) to reflect exactly which guard failed.

Read the required-claim and value checks separately

Requiring a claim does not guarantee it’s sensible. For instance, a token with exp: 0 (year 1970) would fail only on the value check, not the presence check. A malformed exp may fail a separate value-format check; this fixture does not test malformed values. Here we restrict to numeric UTC seconds and avoid type errors. The applied policy is simply “exp present and in future.” This separation means the presence test is binary, and the expiration test is a value comparison; neither implies anything about ordering relative to iat or about maximum lifetime. Those aspects are out of scope. We only care that a future timestamp is there.

6. Use presence-only validation as an offline negative control

Profile C (verify_exp=False, require=["exp"]) is the inverse: we enforce exp presence but skip the time check. This serves as a negative control. Indeed, PyJWT behavior (illustrated in examples) is that if verify_exp=False, an expired token is decoded normally. Our expected outcome is "ACCEPT" for Profile C on the expired case (since it has a present exp, and we turned off verification). The missing_exp case still fails with MissingRequiredClaimError (because we still require exp).

This profile is not a fallback we would actually deploy; it’s to show the insufficiency of presence-checking alone. Accepting an expired token under Profile C highlights that simply having an exp field is not enough to enforce timeliness. In practice, toggling off verify_exp (or not checking the field) would undermine the expiration contract. Profile C shows the opposite flaw of A: it enforces a dummy presence rule but literally admits expired tokens. This is not a secure recovery or compatibility mode; it merely proves the point that presence does not equal validation.

7. Prove that unrelated validation controls remain active

For completeness, we test three more cases with a valid future exp: a bad signature, a wrong issuer, and a wrong audience. In each case, all profiles must still reject the token, because those faults are orthogonal to exp. The expected outcomes are:

•   Wrong signature: Every profile is expected to yield InvalidSignatureError, since signature verification (verify_signature=True) fails first, regardless of any exp logic.

•   Wrong issuer: Every profile is expected to yield InvalidIssuerError because we set verify_iss=True and the iss claim doesn’t match our configured issuer.

•   Wrong audience: Every profile is expected to yield InvalidAudienceError since verify_aud=True and the aud claim isn’t the expected "urn:refonte:expiry-lab".

These expected outcomes (signature/issuer/audience errors) are the same across A, B, and C. We did not mix two problems in one token so that exception messages remain clear. A complete matching run would confirm that changing exp behavior did not inadvertently disable the other checks exercised by the fixture. Signature, issuer, and audience validation should still work as configured. (PyJWT’s documentation and RFC best practices both emphasize validating these fields separately.)

Separate a decoded subject from an authorized principal

A successful Profile B decode confirms that the token passed the configured signature, issuer, audience and expiration checks. It does not mean the "sub": "synthetic-user" claim corresponds to a real user or that any permissions are granted. In this lab we do not look up sub or grant access beyond decoding. The token’s subject is treated as an opaque identifier. Business logic (e.g. database lookup, object permissions, action authorization) lies outside the JWT gate. Decoding success just means “the token is structurally and cryptographically valid with respect to our validation rules,” not that the bearer is an authenticated/authorized user. (Scope and permission enforcement would be another layer beyond this expiration/profile check.)

8. Keep clock behavior from invalidating the result

The test defines t0 = current UTC epoch seconds at start. We then set exp = t0+3600 (future) for “valid” tokens, or exp = t0-3600 (past) for “expired.” A leeway of 0 means we intentionally disallow even 1 second of skew. To ensure consistency, the script checks that each time.time() call around the jwt.decode is within ±60 seconds of t0. If either wall-clock reading falls outside that window, or total monotonic elapsed time exceeds 60 seconds, the script records ENVIRONMENT_HOLD and stops. This avoids flakiness. We purposely do not test boundary values like “exp = t0” or use small sleeps, and we rely on PyJWT’s documentation that current time is compared in UTC. In production, one might allow a minute of leeway, but for clarity we made expiration clearly expired or clearly valid by an hour.

These choices are part of the experimental fixture (not recommendations). We could have introduced a small clock skew tolerance (leeway) in code, but we kept it zero to focus on the fundamental check. If the run fails the clock window, retain the partial report as incomplete evidence and rerun before making an acceptance decision.

9. Capture decisions without recording bearer credentials

The script emits a JSON report summarizing each case/profile result. Each completed row includes: case name, profile ID, whether exp was present, the expected outcome (e.g. "ExpiredSignatureError"), the observed outcome (exception class or "ACCEPT"), and timestamps. Importantly, it does not log any actual token strings or secret keys. The field contains_encoded_tokens_or_keys is set to false, and we never include claims beyond indicators. This is intentional: we only record claim presence and reason codes, not secrets. (Storing raw tokens or keys would violate good logging practice.) The output is akin to structured observability data, with fixed schema fields. We follow an approach similar to API security and observability best practices: gather enough metadata (event times, validator version, expected vs actual class) to audit behavior, while redacting anything sensitive.

The report records Python and platform information. After the relevant initialization succeeds, it also includes the PyJWT version, the initial UTC timestamp, allowed_algorithm, and leeway_seconds. A completed row has status "MATCH" when observed and expected outcomes agree, or "MISMATCH" when they differ. The overall status becomes "PASS_REFERENCE_MATRIX" only after all 18 rows match. A caught environment blocker produces "ENVIRONMENT_HOLD"; another caught failure produces "FAIL".

Treat an incomplete matrix as incomplete evidence

We require exactly 6 cases × 3 profiles = 18 records. The script’s require() checks ensure there are no duplicates or omissions. The report and new rows begin with "INCOMPLETE" status. A caught environment issue changes the report to "ENVIRONMENT_HOLD", while another caught failure changes it to "FAIL"; individual unfinished rows may remain incomplete. Incomplete runs (missing cases/profiles) must be considered incomplete evidence; they are not a basis for policy decisions. Likewise, an environment hold or exception yields no PASS. The table of expected outcomes was authored before any decoding, so we don’t “fill in” results after the fact; we only compare. In short, an empty or partial report is not a verdict; it just means “retest needed.” Only a full, matched matrix qualifies as a successful reproduction of the test scenario.

10. Inventory issuer cohorts before tightening admission

Assuming we want to enforce requiring exp, we must carefully apply this change. First, inventory all token issuers and all places in code where this JWT is validated. We might instrument the decoder (in a staging/test environment) to log missing exp instances. We should NOT simply deploy and reject all tokens, because that could cause service outages. Instead, gather telemetry: look for tokens that lack exp, and see which clients or services send them. Then engage the relevant teams: inform each issuer owner that their tokens need an exp (and set an appropriate lifetime). Also check where the decode calls happen and update configs if needed.

During this analysis, the current system (with lenient expiration checking) should remain active so valid traffic isn’t broken. For example, one could enable logging for missing exp (without rejecting) or run these tests against a copy of the system. The key is to treat the experiment results as evidence of who must fix what, without prematurely weakening security.

As recommended by development best practices, we follow an API lifecycle and ownership approach: assign a unique owner to the issuing component and another to the validation component, plus an overall product/DevSecOps sponsor. These owners will coordinate implementing the change. We avoid deploying an all-accepting rule just to measure tokens: that would itself be insecure. Use this controlled lab or synthetic data to verify the proposed contract, and use the issuer inventory to identify producers that need updates.

11. Repair the producer-consumer contract and rerun

Once owners are identified, fix the root causes. On the producer side, ensure every JWT created in the identified context includes a correct exp (for example, set it to now+1h). On the consumer side, configure the decoder (in the actual running service or library adapter) to include exp in options.require and keep verify_exp=True. Review the deployment: if using a wrapper or framework (e.g. a config file or annotation) we ensure it reflects these settings.

After applying the fixes (and redeploying to a test environment), rerun the same six-token tests. For Profile B, the valid token should continue to succeed, while the missing-exp and expired tokens should fail with their expected exceptions. We compare the class names of rejections rather than just “pass/fail,” because knowing whether we got a MissingRequiredClaimError vs any other error indicates we fixed the intended condition. This confirms the contract is properly enforced. Only when the observed classes match the expected (as in our reference matrix) do we mark the upgrade successful.

Note: This offline test alone does not prove the real HTTP service is secure; one must verify the change in the actual runtime environment (which might require separate integration tests or service-level checks).

12. Recover from a failed rollout without disabling expiration checks

If for some reason the change breaks an important flow, do not fix it by permanently accepting missing or expired tokens. Restore a previously verified release that enforces the approved expiration contract, if one exists; otherwise hold the affected rollout while repairing the producer or consumer. Keep verify_exp enabled. Optionally, you could temporarily relax the requirement but only in a staging branch or with an explicit hold, not on production tokens.

Also remember: exp rejection concerns the token just processed; it does not retroactively revoke or log out other sessions. The policy of “missing/expired tokens are rejected” does not imply anything about existing sessions or refresh tokens. Token revocation (e.g. when a user leaves or a breach occurs) is handled separately. In particular, rejecting a JWT for bad exp does not sign out or offboard the user; that's a different lifecycle event. For that, see session revocation after Entra offboarding, which details how to coordinate terminating sessions or credentials. Here, we just ensure our admission gate focuses on current token validity.

Keep expiration and revocation responsibilities separate

If we decide to suspend or change this expiration policy, it should not be mixed with user or session revocation. For example, we should not treat a missing-exp token as a trigger to revoke an account. Likewise, we do not stop caring about exp simply because a user session needs to end; those are orthogonal concerns. In other words, missing/expired exp = token admission issue; account offboarding = separate procedure. Any recovery plan must clearly document which policy was changed and who authorized it, keeping the lines drawn as they are in session revocation after Entra offboarding.

13. Apply the acceptance matrix and assign owners

The following table summarizes the expected outcomes and identifies the candidate profile. Adoption requires a complete matching run and verification of the actual runtime configuration.

Profile

Valid token

Missing-exp token

Expired token

Signature, issuer and audience errors

Adopt?

A: optional exp

ACCEPT

ACCEPT (bad)

ExpiredSignatureError

All errors still occur

No

B: required and verified

ACCEPT

MissingRequiredClaimError

ExpiredSignatureError

All errors still occur

Candidate

C: offline presence only

ACCEPT

MissingRequiredClaimError

ACCEPT (bad)

All errors still occur

No

In this expected matrix, only Profile B is a candidate for our strict policy: it accepts the valid token and rejects both missing-exp and expired cases (with the expected exceptions). A and C are each expected to admit an unwanted token (missing_exp or expired), so they are not adopted. The signature/issuer/audience columns show that all profiles still reject those faulty tokens (indicated simply by “errors still occur”).

We then assign stakeholders: the token issuer owner (who must fix producers), the validator (service) owner (who configures the decoder), the application security reviewer (oversight), and a rollout coordinator (who confirms the fix). Triggers for re-running this test include any library or config change (e.g. PyJWT upgrade), another token type being introduced, any middleware/wrapper modifications, or changes to clock synchronization policy. If any such changes occur, the matrix should be re-validated. We do not consider this a one-time certification of JWT security; it’s a scoped gate: “Profile B’s expiration contract is enforced.”

14. Develop the application-security foundations behind the gate

This exercise highlights the practical skill of translating a written security policy (“tokens must have a valid expiration”) into concrete positive and negative test cases and interpreting the evidence. It emphasizes precise configuration over assumptions: a green test result means the decoder did exactly what the profile said, not that the token is magically “secure” in every sense.

For engineers seeking to deepen these skills (cryptography, token handling, secure CI/CD, etc.), Refonte Learning’s Cybersecurity & DevSecOps program offers structured training. This three-month, part-time course (typically 12–14 hours per week) covers cryptography, secure CI/CD pipelines, static/dynamic analysis (SAST/DAST/IAST), OWASP threat modeling, incident response, and automation (Wireshark, Metasploit, Nmap, Burp Suite, Kali Linux, etc.). An industry mentor guides virtual labs and exercises. Graduates earn both a Training Certificate and a Certificate of Internship upon completion. Admission requires candidates to be working toward a bachelor’s or higher-level degree. This hands-on curriculum would reinforce the principles seen here, including proactive security testing, JWT best practices, and secure deployment pipelines, without implying any endorsement of specific libraries or the content of this lab.