A Quick JWT Overview for Developers and Security Practitioners (Part 1)
    Security

    A Quick JWT Overview for Developers and Security Practitioners (Part 1)

    Most developers treat a JWT as a signed JSON blob. The RFC has a more precise definition — and the gap between the two is where most vulnerabilities live.

    Reverse PolarityJanuary 8, 202610 min read

    RFC 7519 is a compact document — thirty pages in the published form, with a straightforward abstract and a table of contents that looks simple enough. But JWT is one of those specs that gets widely implemented and rarely fully read, and the gaps between casual understanding and what the RFC actually says are where a surprising number of bugs and vulnerabilities originate.

    This isn't a getting-started guide. If you've never used a JWT before, stop here and start somewhere else. What follows are eight specific things the RFC says that developers and security reviewers consistently get wrong or gloss over — the kind of details that matter when you're doing a code review, auditing an integration, or trying to figure out why your token validation is quietly accepting things it shouldn't.

    Diagram — JWT Structure (RFC 7519)
    Diagram

    1. JWT is an abstract container, not a specific wire format

    This is the foundational misunderstanding that everything else flows from. Most developers hear "JWT" and think of the three-part dot-separated string they see in Authorization headers. That string is actually either a JWS (JSON Web Signature) compact serialization or a JWE (JSON Web Encryption) compact serialization. JWT is the layer above both of them.

    Section 1 of the RFC states it plainly: "The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure." The JWT spec itself doesn't define the wire format — that's delegated to RFC 7515 (JWS) and RFC 7516 (JWE). RFC 7519 defines what goes in the payload and how the claims are structured.

    In practice, nearly every JWT you will encounter in the wild is a JWS. JWE (encrypted JWT) is a different beast with a five-part structure:

    BASE64URL(JWE Protected Header) .
    BASE64URL(JWE Encrypted Key) .
    BASE64URL(JWE Initialization Vector) .
    BASE64URL(JWE Ciphertext) .
    BASE64URL(JWE Authentication Tag)
    

    Compare that to a JWS compact serialization, which has three parts:

    BASE64URL(JOSE Header) .
    BASE64URL(JWS Payload) .
    BASE64URL(JWS Signature)
    

    The number of dots tells you which one you're looking at. Five-part tokens carry encrypted content where the payload is not readable without decryption. Three-part tokens carry a signed (or, as we'll get to, unsigned) payload that is fully readable by anyone who Base64URL-decodes the middle segment. The RFC explicitly notes that support for encrypted JWTs is OPTIONAL — a conforming JWT implementation only has to handle JWS.


    2. The three Base64URL parts contain exactly what you think — but with one catch

    With a JWS-based JWT, the three dot-separated segments are: the JOSE header, the claims payload, and the signature. All three are Base64URL-encoded. The header and payload encode UTF-8 JSON; the signature encodes raw bytes.

    The catch: Base64URL is not the same as Base64. The alphabet substitutes + with - and / with _, and the padding character = is omitted entirely. This matters in practice because standard library Base64 decoders will fail or produce garbage on JWT segments if you don't use the URL-safe variant. The RFC is explicit: "Base64url Encoding" per the JWS spec, with no line breaks, no whitespace, no padding.

    The reason for the URL-safe variant is in the name — these tokens are designed to travel in HTTP headers and URI query parameters, where +, /, and = are either reserved characters or require percent-encoding. Base64URL eliminates that problem.

    What the header actually contains:

    {
      "typ": "JWT",
      "alg": "HS256"
    }
    

    What the payload (JWT Claims Set) actually contains:

    {
      "iss": "https://auth.example.com",
      "sub": "user_123",
      "aud": "api.example.com",
      "exp": 1717286400,
      "iat": 1717282800
    }
    

    Decoding these in Go is straightforward, but make sure you use base64.RawURLEncoding, not base64.StdEncoding and not base64.URLEncoding:

    import "encoding/base64"
    
    func decodeSegment(seg string) ([]byte, error) {
        // RawURLEncoding handles the no-padding, URL-safe alphabet
        return base64.RawURLEncoding.DecodeString(seg)
    }
    

    The RFC makes a point that the JSON in both the header and the payload may contain whitespace and line breaks — the spec explicitly permits this and says no canonicalization is required before encoding. That means two JWTs with semantically identical payloads but different whitespace will have different Base64URL representations and different signatures. A common error is stripping whitespace from a JWT payload before checking the signature, which will break verification.


    3. The alg: "none" algorithm is in the spec on purpose

    Section 6 defines the "Unsecured JWT": a JWT where alg is set to "none" and the signature segment is the empty string. The token still has three parts separated by dots — the trailing period is required — but there is nothing after the last dot.

    eyJhbGciOiJub25lIn0
    .
    eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODB9
    .
    

    This is not a bug or an oversight. The RFC describes a legitimate use case: "use cases in which the JWT content is secured by a means other than a signature and/or encryption contained within the JWT (such as a signature on a data structure containing the JWT)." The classic example is a JWT embedded inside a larger signed structure — the inner token doesn't need its own signature if the outer envelope already provides integrity.

    The security problem arises because the spec also mandates that a conforming implementation MUST implement "none". Section 8 on implementation requirements says "HMAC SHA-256 ('HS256') and 'none' MUST be implemented by conforming JWT implementations." This means every compliant JWT library ships with "none" support.

    The vulnerability appears when a validator accepts whatever alg value appears in the header and then verifies accordingly. An attacker takes a valid signed token, changes the alg to "none", removes the signature, and presents it. If the library processes "none" without the application explicitly disabling or ignoring it, the token passes verification.

    Good JWT libraries expose an explicit allowlist of accepted algorithms at the point of verification:

    // Accept only specific algorithms — never derive the algorithm from the token itself
    token, err := jwt.Parse(tokenString, keyFunc,
        jwt.WithValidMethods([]string{"HS256", "RS256"}),
    )
    

    The RFC's validation procedure (Section 7.2) says: "unless the algorithms used in the JWT are acceptable to the application, it SHOULD reject the JWT." The operative word is "application" — the decision about acceptable algorithms belongs to the verifier, not to the token presenter.


    4. The registered claim semantics are stricter than most code treats them

    Section 4.1 defines seven registered claim names. All of them are optional. But "optional" means optional to include — if they are present, their semantics are normative and non-negotiable.

    exp (expiration time): The current time MUST be before the value in exp. The RFC allows "small leeway, usually no more than a few minutes, to account for clock skew." The value is a NumericDate — seconds since the Unix epoch as a JSON number, not a string, not an ISO 8601 timestamp.

    nbf (not before): The mirror image of exp. The current time MUST be after or equal to the nbf value. This claim is often confused with iat. They are not the same thing. nbf is about when the token becomes valid. A token could be issued at time T but not be valid until T+300 — you'd set iat to T and nbf to T+300. If nbf is absent, there is no not-before constraint. The RFC says nothing about inferring a not-before from iat.

    iat (issued at): Records when the token was issued. The RFC notes this "can be used to determine the age of the JWT." It is purely informational — the RFC does not say that implementations MUST reject tokens based on iat age. If you want to reject tokens older than N seconds, you implement that logic yourself using iat; the spec doesn't mandate it.

    aud (audience): This one has a sharp edge. If the aud claim is present, the validator MUST identify itself with a value in the audience list. If it doesn't match, the token MUST be rejected. The aud value can be either a single string or an array of strings, and matching is case-sensitive. What many implementations get wrong is validating the aud only if the application "cares" about it. The RFC is unambiguous: presence of aud obligates the validator to check it.

    // Example: verifying all temporal claims plus audience
    func validateClaims(claims jwt.MapClaims, audience string) error {
        if !claims.VerifyExpiresAt(time.Now().Unix(), true) {
            return errors.New("token is expired")
        }
        if !claims.VerifyNotBefore(time.Now().Unix(), false) {
            return errors.New("token not yet valid")
        }
        // aud validation: required if present
        if aud, ok := claims["aud"]; ok {
            if !matchesAudience(aud, audience) {
                return errors.New("token audience mismatch")
            }
        }
        return nil
    }
    

    NumericDate values deserve special attention. The RFC defines them as "the number of seconds from 1970-01-01T00:00:00Z UTC until the specified UTC date/time, ignoring leap seconds." The "ignoring leap seconds" is deliberate — it's standard POSIX time, where each day is exactly 86400 seconds. Non-integer values are permitted by the spec. Most code treats these as int64; libraries that deserialize them as float64 can lose precision for timestamps far in the future, though this is largely academic for access tokens.


    5. The alg header lives in the JOSE header, not the payload — and that asymmetry matters

    When you verify a JWS, you're verifying that the signature over BASE64URL(header).BASE64URL(payload) is valid under the stated algorithm and key. The algorithm specification lives in the header, which is outside the signed region in a critical sense: the header itself is Base64URL-encoded and included in the signed input, so it's technically covered. But the algorithm selection happens before signature verification — the verifier reads alg from the header to know which algorithm to use, then verifies the signature.

    This is why the "accept whatever algorithm the token claims" pattern is dangerous. The header is signed, but you can't verify the signature without first deciding which algorithm to use, and that decision comes from the header. If your code does:

    // WRONG: reading alg from the untrusted token to select verification method
    alg := token.Header["alg"].(string)
    key := selectKey(alg, kid)
    verify(token, key, alg) // attacker controls alg
    

    ...then you've handed the attacker control of the verification logic. The correct pattern is to specify the algorithm(s) you accept at the point of calling your JWT library, independent of what the token claims.

    Section 7.2 of the RFC spells out the validation order: parse the header, verify the JOSE header contains only understood parameters, determine whether it's a JWS or JWE, then proceed with verification. The algorithm check is an application-level step that must happen after parsing but before accepting the result.


    6. typ and cty headers serve different purposes and are often misused

    typ (type): Declared in the header to tell JWT-consuming applications what kind of object this is. The RFC says it is intended to disambiguate among different kinds of objects that "might be present" in an application data structure. If you already know you're dealing with a JWT, typ adds nothing. The RFC explicitly says "It will typically not be used by applications when it is already known that the object is a JWT."

    When present, the recommended value is "JWT" (uppercase, for compatibility with older implementations, since media type names are technically case-insensitive but legacy parsers aren't always). The JOSE header is processed by the JWT implementation; typ is processed by the JWT application. Libraries typically expose it as a header parameter you can read, not something the library enforces.

    cty (content type): This one matters specifically in the nested JWT scenario. In normal usage, the RFC says cty is NOT RECOMMENDED. When you have a nested JWT — a JWT that is itself the payload of an outer JWS or JWE — cty MUST be present in the outer token's header, and its value MUST be "JWT". This is how the validator knows to recurse.

    // Outer JWE header for a nested JWT (sign-then-encrypt):
    {
      "alg": "RSA1_5",
      "enc": "A128CBC-HS256",
      "cty": "JWT"
    }
    

    The validation algorithm in Section 7.2 uses cty as the trigger for recursive processing: if the JOSE header contains "cty": "JWT", the decrypted/verified payload is itself a JWT that must be validated from the start. If your application uses nested JWTs and you're not checking for cty, you're likely not validating the inner token.


    7. Public versus private claims, and the collision problem

    The RFC defines three categories of claim names. Registered claims are the seven in Section 4.1 (iss, sub, aud, exp, nbf, iat, jti). Public claims are names registered in the IANA JSON Web Token Claims registry — a process involving a review period, a designated expert, and a mailing list. Private claims are everything else.

    Private claims are where most application-specific data lives: user_role, tenant_id, permissions, and so on. The RFC says: "Private Claim Names are subject to collision and should be used with caution." This isn't boilerplate. The collision concern is real in multi-tenant environments, federated systems, or anywhere JWTs cross organizational boundaries.

    Consider: two services independently decide to use "role" as a private claim. Service A issues a token with "role": "admin" meaning one thing; Service B's validator encounters that token and interprets "role": "admin" in its own context. If neither service registered the claim name or scoped it to a collision-resistant namespace (a domain you control, a UUID-based URI, an OID), there's no technical protection against this ambiguity.

    The safe patterns are either registering claim names via IANA or using collision-resistant names:

    {
      "sub": "user_123",
      "https://api.example.com/roles": ["admin", "auditor"],
      "https://api.example.com/tenant": "acme-corp"
    }
    

    Using a full URI as a claim name is verbose but unambiguous. Many OpenID Connect implementations use exactly this pattern for extended claims. The RFC gives "http://example.com/is_root": true as its own example in Section 3.1 — it's not just illustrative style, it's demonstrating the collision-resistant namespace pattern.


    8. Duplicate claim names and what parsers are allowed to do

    Section 4 contains a note that's easy to miss: "The Claim Names within a JWT Claims Set MUST be unique; JWT parsers MUST either reject JWTs with duplicate Claim Names or use a JSON parser that returns only the lexically last duplicate member name."

    That second option is significant. A parser that silently takes the last occurrence of a duplicate key is conformant under the RFC. The practical consequence: if an attacker can craft a token with a duplicate claim — say, two exp fields — and your validator processes the first one (which is far in the future) while your application logic reads the second one (which is valid), you have a bypass.

    This is a real attack class, not theoretical. Different JSON libraries handle duplicate keys differently — some throw, some take first, some take last. The RFC permits both "reject" and "take last" but does not permit "take first." If you're doing a security review of a JWT-based system, verifying that the JSON parser and the JWT library agree on duplicate key handling is worth a few minutes.

    // If using encoding/json in Go, duplicate keys result in the last value winning.
    // This matches what RFC 7519 permits but can cause subtle bugs if your
    // validation path and your claims-reading path use different parsers.
    
    var claims map[string]interface{}
    err := json.Unmarshal(payloadBytes, &claims)
    // claims["exp"] will be the last "exp" value if duplicates exist
    

    The safest stance is to use a JWT library that explicitly rejects tokens with duplicate claim names, or to validate the raw JSON for uniqueness before processing.


    The structural and semantic issues covered here — the JWS/JWE distinction, the algorithm selection model, the temporal claim semantics, the collision risk in private claims — are the foundation. Part 2 will focus on what happens when these building blocks get assembled carelessly: algorithm confusion attacks where an RS256 public key gets used as an HMAC secret, weak symmetric keys that make HS256 tokens brute-forceable offline, missing aud and iss validation that allows tokens from one service to be replayed against another, and the practical implications of "none" in libraries that don't lock down their accepted algorithm list. The spec is clean; the attack surface emerges from the gap between what the RFC says and what most code actually does.


    Sources

    More Articles