TL;DR
- Signatures aren’t enough: Signature checks via OIDC cover only 15% of leaked JWTs, and a valid signature names neither the service that issued a token nor whether that token still works.
- Attribution: 345 rules matched 86% of about 2.4 million tokens to a named service.
- Validation: Replaying each token against its service’s own API found 334,762 tokens still valid at check time.
- Detection coverage: GitGuardian now detects JWTs from 14 services and validates some of them.
Florent Leroy
Florent is a cybersecurity engineer and recent graduate who completed a research internship at GitGuardian.JSON Web Tokens (JWTs) leak more every year: the number found in public GitHub repositories doubled between 2025 and 2026. We traced leaked JWTs back to 345 services and got a definitive answer for 75% of attributed tokens. The first half of this post is hands-on. We take a Google id_token and validate it one command at a time, using curl, jq, openssl, and a few lines of Python. Each step shows the command and its output, so you can replay it on your own tokens. The second half shows how we scaled that process to 2.4 million leaked JWTs.
JWT state of the art
JWTs are a compact and widely used way of transmitting data between two parties. They sit at the center of authentication protocols such as OpenID Connect (OIDC), and they keep leaking, mostly in GitHub repositories.
Counting those leaks, however, says little on its own. The question that matters is what an attacker who finds a token can do with it: read sensitive data, or sign in as a user. This means qualifying and quantifying the problem rather than just measuring its volume. That starts with the format itself: the JWTs used in authentication protocols are JSON Web Signatures (JWS).
JWS structure

As shown in the figure above, a JWS is made of three base64url-encoded strings separated by dots. The Header says what protects the token: the signature algorithm and the ID of the signing key. The Payload carries the data to transmit, called claims. The Signature can be symmetric or asymmetric.
The Header and Payload are only encoded, not encrypted, so decoding them gives the plaintext back. That is the first step in evaluating a JWT.
A JWT:
eyJhbGciOiJSUzI1NiIsImtpZCI6Ijk0M2EzYTVkN2Q5MTk2MjVhNDU0ZTQ4OWI3NWMyOWFkYWI1N2FjYmEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJpYXQiOjE3NzMxMTA4NTUsImV4cCI6MTc3MzExNDQ1NX0.Fbl-hZPTLnZJaL_igvzp_bnwVKEfyJyekzBiERKsaa4aMuUkL0qnMMB6X3zjfxSRqWZA8UpYWhpDMYq7NEVLMhk7Sj0FD_ug35Xx3q4tnw6v_nk22WhdgB6QOSzCj0ocwQsNIT1J4zbmXNVwZxq7GBUbC9L79H1OvB5C1fdh2lx3cuocCMOyWFhrB_4tUJexXoNYbqVFInHr2upSwHPpvm_6QAYuD2saBpWhDFAkqitof86fgusy4oUPyjHSdqqGTJ8A6I4IdhuSwSfqapIpe3fP0Em70hTdxxmgcG1Qc96eKQB4a1PiLe3Vh6lvIaxTGtjOoQ6utl4X_7ZT8DdJgA
The Same JWT but decoded
HEADER = {"alg":"RS256","kid":"943a3a5d7d919625a454e489b75c29adab57acba","typ":"JWT"}
PAYLOAD =
{"iss":"https://accounts.google.com","aud":"407408718192.apps.googleusercontent.com","iat":1773110855,"exp":1773114455}
For such JWTs with an asymmetric signature (for example RS256), OIDC offers a way to validate them.
OIDC
OpenID Connect (OIDC) is an authentication protocol built on OAuth 2.0. It allows an application (the Relying Party, or RP) to authenticate a user using an Identity Provider (IdP) such as Google, Microsoft, or Okta.

At the end of the flow, the IdP issues three tokens, and two of them dominate what we find in public repositories: the id_token and the access_token. The id_token (always a JWT) says who signed in and is meant for the application that requested it, not to be sent to an API. The access_token is the credential an application presents to an API to access protected resources and obtain additional user information. JWTs issued by IdPs are signed asymmetrically. To verify the authenticity and integrity of the received claims, the RP must validate the JWT signature, which requires retrieving the IdP's public keys.
OIDC standardizes how to find them: every provider serves a discovery document at /.well-known/openid-configuration under its issuer URL, and that document points to the key set the provider currently publishes. This mechanism, called OIDC discovery, makes a token checkable from its iss claim alone.
Manual validation of a JWT signature with OIDC
The token itself says who signed it, and OIDC discovery tells us where to find the matching public key. To do the validation, we can act as a Relying Party when it receives a JWT, with six manual steps:
1. Decode the token
We decode the JWT into two variables: one for the header, one for the payload. JWT segments use base64url (-and _ instead of + and /) with the = padding stripped. The standard base64 command rejects that, hence the helper.
JWT='eyJhbGciOiJSUzI1NiIsImtpZCI6Ijk0M2Ez...'
# base64url -> raw bytes
d64() { local s; s=$(tr '_-' '/+'); while (( ${#s} % 4 )); do s+='='; done; printf '%s' "$s" | base64 -d; }
HEADER=$(cut -d. -f1 <<< "$JWT" | d64)
PAYLOAD=$(cut -d. -f2 <<< "$JWT" | d64)
The header gives the signature algorithm (alg) and the identifier of the key used (kid); the payload gives the issuer (iss) and the temporal claims exp (expiry) and iat (issued at).
HEADER = { "alg": "RS256", "kid": "943a3a5d7d919625a454e489b75c29adab57acba", "typ": "JWT" }
PAYLOAD = { "iss": "https://accounts.google.com", "aud": "407408718192.apps.googleusercontent.com", "iat": 1773110855, "exp": 1773114455 }
2. Check the claims
Verifying the signature of an expired token is pointless, so we start with the temporal claims. The exp claim gives the expiry time in epoch seconds, and we compare it with the current time.
jq --argjson now "$(date +%s)" \
'{iss, aud, exp, expired: (.exp < $now), expires_in_s: (.exp - $now)}' <<< "$PAYLOAD"
{ "iss": "https://accounts.google.com", "aud": "XXX.apps.googleusercontent.com", "exp": 1773114455, "expired": true, "expires_in_s": -60123456 }
3. Ask the issuer for its OIDC configuration
We use the standardized discovery mechanism at /.well-known/openid-configuration under the issuer URL. Fetching it turns the iss claim into the address of the issuer's public key set (jwks_uri).
ISS=$(jq -r .iss <<< "$PAYLOAD")
curl -s "$ISS/.well-known/openid-configuration" | jq -r '.issuer, .jwks_uri'
https://accounts.google.com
https://www.googleapis.com/oauth2/v3/certs
The issuer field returned must be strictly equal to the iss claim; otherwise, the discovery document does not describe the entity the token claims to come from.
4. Download the JWKS and select the key that signed this token
The JSON Web Key Set (JWKS) is the list of public keys the issuer currently publishes; the kid of the header designates one of them. Issuers rotate these keys regularly, which is why the HTTP response carries a Cache-Control: max-age telling how long the set may be reused.
JWKS_URI=$(curl -s "$ISS/.well-known/openid-configuration" | jq -r .jwks_uri)
KID=$(jq -r .kid <<< "$HEADER")
JWK=$(curl -s "$JWKS_URI" | jq -c --arg kid "$KID" '.keys[] | select(.kid == $kid)')
echo "$JWK"
{ "kty": "RSA", "alg": "RS256", "use": "sig", "kid": "943a3a5d...", "n": "ww0Rlhxc...", "e": "AQAB" }
A JWK is not a ready-to-use key file. n (the modulus) and e (the exponent) are the two integers an RSA public key is made of, written as raw bytes and encoded in base64url.
If no key matches the kid, the token cannot be validated this way: either it was signed by a key that has since been rotated out, or it does not come from this issuer at all.
5. Rebuild the public key
openssl cannot read a JWK, so we use a short Python script to write the public key as a standard PEM file, which openssl reads at the next step.
python3 -c '
import base64, json, sys
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
jwk = json.load(sys.stdin)
b64uint = lambda s: int.from_bytes(base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)), "big")
key = rsa.RSAPublicNumbers(e=b64uint(jwk["e"]), n=b64uint(jwk["n"])).public_key()
sys.stdout.buffer.write(key.public_bytes(serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo))
' <<< "$JWK" > pub.pem
6. Verify the signature
Verifying the signature means rebuilding what was signed, then checking the signature against it. What was signed is the first two segments joined by a dot, taken exactly as they appear in the token, so we write them to a file as is. The third segment is the signature itself, so this one we decode back to raw bytes into a second file. openssl dgst -sha256 -verify then hashes the signing input. It confirms that the private key matching our JWKS public key produced the signature.
printf '%s' "${JWT%.*}" > signing_input.bin
cut -d. -f3 <<< "$JWT" | d64 > sig.bin
openssl dgst -sha256 -verify pub.pem -signature sig.bin signing_input.bin
Verified OK
This proves the token was issued by the holder of the private key and has not been modified since. It says nothing about whether it is still usable.
Limitations
This approach has two limits. It can only be tested on asymmetrically signed tokens, which represent only 15% of the JWTs we detect, and exclusively for services that follow the OIDC protocol. And even for tokens that can be tested, a valid signature is a weak answer. OIDC validation proves that a token was signed by a key we could fetch, and that is all: it says nothing about which service issued it, or who it was issued to. A detection that stops there is noise. For the result to be usable, the token has to be tied back to a named service, which is exactly what attribution does.
Attribution
To assign a JWT to a service, we read its payload as a JSON object and look at a few of its claims. The most common are iss and aud.
After analyzing several hundred JWTs, we managed to associate the values of the iss and aud fields with services. This works for well-known identity providers such as Google, Apple, OpenAI, or AWS, but also for self-hosted tools that mint their own tokens.
Example (AWS Cognito):
{
"sub": "d24c2906-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"iss": "https://cognito-idp.eu-west-1.amazonaws.com/eu-west-1_XXXXXXXXX",
"version": 2,
"client_id": "XXXXXXXXXXXXXXXXXXXXXX",
"origin_jti": "9f6a724f-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"event_id": "a2ac1eaa-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"token_use": "access",
"scope": "aws.cognito.signin.user.admin openid profile",
"auth_time": 1777400328,
"exp": 1777436613,
"iat": 1777433013,
"jti": "53453064-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"username": "XXXX@XXX.com"
}
Some services set neither iss nor aud, and are recognized by claims only they use: Umami Analytics tokens, for instance, carry websiteId, sessionId and visitId.
Example (Umami Analytics)
{
"websiteId": "42388b3e-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"sessionId": "21513f22-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"visitId": "74dde7b2-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"iat": 1773409275
}
To avoid writing a dedicated function for each service, we adopted a Lucene-inspired rule syntax. It lets us match a specific field against a value, use wildcards (*) and regular expressions, and combine multiple conditions with Boolean operators.
Rule for attributing the Cognito token:
iss:/cognito-idp\.[a-z0-9-]+\.amazonaws\.com/
This pattern assigns the aws-cognito label to any JWT whose iss claim contains cognito-idp., followed by a sequence of letters, digits, or hyphens (the AWS region), and then .amazonaws.com.
Here the rule keys on iss, but other services are better identified by aud. Adobe, for instance, issues tokens whose audience names the Adobe IMS endpoint:
"aud": "https://ims-na1.adobelogin.com/c/<client_id>"
We applied this method to every JWT detected between March 1 and Sept. 1, 2026: 2,420,381 unique tokens. The rules now cover 345 services, and they attributed 2,078,336 of those tokens, or 86% of them.

Live validations
Attribution tells us which service issued a token, which puts us back on ground that GitGuardian knows well: for a known service, knowing where to send a secret to check whether it is still valid.
Doing that takes three pieces of information:
- Where to send the token: the endpoint URL, and whether it expects a
GETor aPOST. - How to carry it: in an
Authorization: Bearerheader, in a custom header, in the request body, or as a query parameter. - How to read the answer: which status code means the token is still valid, and which means it was rejected.
For the AWS Cognito access token shown earlier, the AWS Cognito token documentation points to the GetUser action of the User Pool API. The region in the URL comes from the token's own iss, and the token travels in the JSON body:
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST "https://cognito-idp.eu-west-1.amazonaws.com/" \
-H 'Content-Type: application/x-amz-json-1.1' \
-H 'X-Amz-Target: AWSCognitoIdentityProviderService.GetUser' \
-d "{\"AccessToken\": \"$JWT\"}"
The response's status code then decides the token's fate: a 200 means the token is still valid, while a 401, or sometimes a 403, is the usual way an API says it is not. Not every service follows that convention, and the divergence almost always concerns the rejection code: AWS, for instance, returns 400 for a Cognito token that is expired or revoked.
We ran those checks on every JWT detected and attributed between March 1 and Sept. 1, 2026. Over 1.5 million of about 2 million attributed tokens (75%) got a definitive answer: 334,762 were still valid at the time of the check, and 1,241,388 had already been rejected. The remaining attributed tokens have no verdict because these services have no checker yet, or because the check itself failed. The figure below covers June to August, with validation starting the week of June 29. Tokens checked within 24 hours of detection are valid almost 60% of the time.

What the leaked tokens actually unlock
Attribution also answers the question from the start of this post: what can an attacker do with a leaked token? The answer is uneven.
Streaming and IPTV services account for 58% of attributed tokens. The largest single source is Pluto TV, a free ad-supported streaming platform whose users share access keys in public GitHub repositories.
Another 30% come from services with real operational value, such as Supabase, OpenAI, n8n, Lovable, and MetaAPI Cloud. A signature check cannot tell these tokens apart from a streaming key. Attribution can.
From research to product
Since Aug. 5, the rules built for this study have run in production. GitGuardian now detects JWTs from 14 services, including n8n, OpenAI, Supabase, AWS Cognito, Vercel, Atlassian, and Keycloak. Live validation covers some of them, including Supabase and MetaAPI Cloud.
A JWT found in a public repository is no longer reported as a generic “JSON Web Token.” It is reported as a service credential, with a valid or rejected verdict when a checker exists. Okta, Azure AD, Clerk, and Cloudflare Access are candidates for the next detectors.

Since early August, attributed tokens quadrupled, and valid tokens grew 20-fold.
Takeaways
- A valid signature proves authenticity, not risk. OIDC checks apply to only 15% of leaked JWTs, and name neither the issuing service nor whether the token still works.
- Attribution names the service. 345 rules matched 86% of about 2.4 million tokens to a named service.
- Validation answers the question that matters. 334,762 tokens were still valid at check time.
- Speed decides the outcome. Tokens checked within 24 hours of detection were valid almost 60% of the time.
When a token leaks, the first day is when it is most likely to still work.
Detect your public secrets on GitHub now

