Context

I recently realized that my understanding of how JWTs are validated was incorrect. I thought the flow worked like this:

The client presents its credentials to Entra ID and gets a JWT for a specific aud (audience). The client then presents that JWT to the resource — an API, for example. The API, after receiving the token, presents it back to Entra ID for validation: essentially asking, “Is this token actually valid and untampered?” Upon successful validation by Entra ID, the API would then check the claims and, if they check out, allow the client to access the API.

The last part of my understanding was wrong.

The API never sends the token back to Entra ID for validation. Instead, the API validates the token locally by cryptographically verifying the JWT’s signature using the public key published by Entra ID.


The actual flow

  1. Client requests token: Client presents its credentials — for example, a client ID and secret — to Entra ID and requests an access token for a particular resource/API.

  2. Entra ID validates: Entra ID validates the client’s credentials and determines what permissions/roles the client has been granted for the requested resource.

  3. Entra ID issues the JWT: Entra ID issues a signed JWT containing three encoded sections.

    1. Header: Specifies the signing algorithm (e.g., RS256) and key ID (kid). Note: There is no encryption involved in standard JWTs.
    2. Payload: The claims (e.g., sub, aud, iss, roles) telling the receiver what the bearer is authorized to do.
    3. Signature: Entra ID computes a hash of Base64(Header) + "." + Base64(Payload) and signs that hash using its Private Key. When we say signing the hash, nothing is done to the computed hash. A string (signature) which is the output of the signing process, is added to the token. Think of a snippet like this
    signature = Sign(Base64Url(header) + "." + Base64Url(payload), private_key)
    

    This is the third section of a JWT.

  4. Token issued: The JWT thus generated is sent back to the client. From this point onwards, the client can present the bearer token when calling the resource for which it was issued. The API validates the token and, if valid, evaluates the claims to determine whether the requested operation is authorized.

  5. Request sent: On receiving the token, the client presents it to the API (aud) in the Authorization header.

  6. Token intercepted: The API receives the request and its authentication library intercepts the token.

  7. Public Key retrieved: The API’s JWT bearer/token-validation middleware (for example, ASP.NET Core JWT Bearer middleware, backed by Microsoft.IdentityModel.Tokens) intercepts the token and obtains the appropriate public signing key from Entra ID’s published OpenID Connect metadata/JWKS endpoint - and caches it.

  8. Algorithm enforcement: The library proceeds to cryptographically verify the signature using the public key. It does not trust the alg field in the header blindly — the library is configured with its own allowlist of acceptable algorithms (e.g. RS256 only) and rejects the token outright if the header claims something else. This matters because of a known attack class (algorithm confusion / alg:none attacks) where a forged token claims a weaker or absent algorithm to try to slip past a careless verifier.

  9. Signature verification: The library independently recomputes the hash of the received header + payload, using the algorithm it has decided to trust — not necessarily whatever the header says. It then runs the verify operation: payload hash + signature + EntraID’s public key go in, and the function checks whether the mathematical relationship between them holds — i.e., whether this signature could only have been produced by the holder of the matching private key, over this exact payload. (The internal mechanics differ by algorithm — RSA and ECDSA don’t work identically under the hood — but the contract is the same: a pass/fail check, not a “decrypt and reveal” step.). Imagine a snippet like this

    valid = Verify( Base64Url(header) + "." + Base64Url(payload), incoming_signature, public_key)
    
  10. Claims & Lifetime check: - If they match, and if the token hasn’t expired and aud is valid, it proves that the token has not been tampered with since it was signed and EntraID (the expected issuer) is the one who signed it.

Signature validity alone isn’t enough to trust the token, though — the library separately checks a handful of claims inside the now-verified payload:

  • exp / nbf — the token is currently within its valid time window.
  • aud — the token was issued for this API, not some other resource.
  • iss — the token came from the expected tenant. Only once both the signature check and these claim checks pass does the library consider the token valid.
  1. Access granted: The client is now allowed to access the API after claims authorization is completed and successful

This is the complete flow. There is no additional call by the receiver of the JWT to EntraID asking - “is this the token you actually sent?”. That bit is done locally at the API end using public key.


Points to remember

Signing process - the signature isn’t itself a key — it’s a fingerprint/output produced by feeding two things into the math function together: the private key, and the payload’s hash. So it’s not “a key linked to a payload,” it’s “a distinct string that only could have been produced by someone holding the private key, operating on this specific payload.” The private key is the secret ingredient; the signature is the result that comes out the other end.

Verification process - the payload + signature + public key are the parameters for the verify function. The function does some advanced math to vouch that the signature indeed relates to the payload and has been signed by the owner of the private key paired to the public key used by the verify method.

Authorization Not Authentication - JWT validation proves the token is unmodified, and tells you which identity it was legitimately issued to and what that identity is authorized to do. It does not prove that whoever is currently presenting the token is that same identity — a stolen-but-valid token passes signature validation perfectly. That gap is closed by transport security (TLS) and short token lifetimes, not by anything in the token itself.