Case Study: jwt.decode() Without jwt.verify() in NangoHQ/nango's OAuth Hooks
By Nark Team
NangoHQ/nango is an open-source OAuth infrastructure platform with 6,000+ GitHub stars. It manages OAuth connections for dozens of providers — Microsoft Teams, OneDrive, Xero, Sellsy, and more. We scanned their TypeScript codebase with Nark and found 6 instances of jwt.decode() used to extract claims from access tokens without jwt.verify(). The shared utility function is named just decode() — there is no indication at the call site that the returned claims are unverified.
The Finding
Nango has a shared JWT utility at packages/shared/lib/auth/jwt.ts:
export function decode(token: string): Record<string, any> | null {
try {
return jwt.decode(token) as Record<string, any>;
} catch {
return null;
}
}
This function wraps jwt.decode(), which base64-decodes the JWT payload without verifying the signature. Anyone can construct a JWT with arbitrary claims, and decode() returns them as if they were legitimate. The function name gives no hint that the result is unverified.
This decode() function is called in OAuth post-connection hooks to extract tenant IDs and user identifiers from access tokens:
// packages/server/lib/hooks/connection/providers/microsoft-teams/post-connection.ts
export default async function execute(nango: NangoSync) {
const connection = await nango.getConnection();
const token = connection.credentials.access_token;
const decoded = decode(token);
const tenantId = decoded?.tid;
// tenantId used for Microsoft Teams org setup
}
Nuance: Not Every Instance Is a Vulnerability
This is defense-in-depth hardening, not an actively exploitable vulnerability. The tokens arrive via server-to-server auth-code exchange over TLS. An attacker would need to compromise the TLS connection to the provider's token endpoint to inject a forged token — at which point jwt.verify() would not help either, because the attacker could also intercept the verification keys.
The risk varies by provider and by what the extracted claim is used for:
| Provider | File | Claim Used | Verdict |
|---|---|---|---|
| Microsoft Teams | microsoft-teams/post-connection.ts | tid (tenant ID) | Acceptable. Microsoft states access tokens are opaque — clients must not validate them. |
| OneDrive | one-drive/post-connection.ts | tid (tenant ID) | Acceptable. Same Microsoft guidance. |
| Xero | xero/post-connection.ts | authentication_event_id | Acceptable. Used only to correlate against tenants already fetched from a verified API call (GET /connections). The tenant ID that gets stored comes from the API response, not the JWT. |
| Sellsy | sellsy/post-connection.ts | corpId | Worth noting. Sellsy does not publish a JWKS endpoint, so verification is not straightforward. A verified API call (e.g., GET /v2/companies) would be a stronger source for corpId. |
| connection.service.ts | Lines 1052, 1062 | exp (expiration) | Informational. Reads exp only for refresh timing calculations. A forged exp could cause premature or delayed refresh, but the worst outcome is a failed API call that retries. |
None of these instances reach the threshold for a security vulnerability. But the code creates a maintenance hazard: the next developer who calls decode(token) has no signal that the result is unverified, and the next provider integration might use the decoded claims for an authorization decision where verification matters.
The Fix
1. Rename the shared utility
The highest-leverage change is renaming decode() to decodeUnverified() with a JSDoc warning:
/**
* Decode a JWT **without verifying its signature**.
*
* WARNING: The returned claims are NOT authenticated. Do not use for
* authorization decisions or to extract identity information.
*
* Acceptable uses:
* - Reading `exp` to schedule token refresh
* - Reading `kid` to select a verification key from JWKS
* - Logging / debugging
*/
export function decodeUnverified(token: string): Record<string, any> | null {
try {
return jwt.decode(token) as Record<string, any>;
} catch {
return null;
}
}
Every existing call site must update from decode(token) to decodeUnverified(token). This is a mechanical rename, but the new name forces every future reader to acknowledge what the function does not do.
2. Annotate the Sellsy usage
// NOTE: jwt.decode() does not verify the token signature.
// Sellsy does not publish a JWKS endpoint for access token verification.
// The corpId is used only as connection configuration metadata.
// TODO: Consider fetching corpId from a verified Sellsy API endpoint
// (e.g., GET /v2/companies) instead of trusting the unverified token.
const decoded = jwt.decode(token) as SellsyDecodedToken;
3. Document acceptable uses
For Microsoft Teams, OneDrive, and Xero, add comments explaining why jwt.decode() is the correct approach for those specific providers — so the next developer does not "fix" them by adding jwt.verify() against tokens that are explicitly opaque.
Why decode() vs verify() Matters
jwt.decode() and jwt.verify() have different trust models:
| Function | What it does | Trust level |
|---|---|---|
jwt.decode(token) | Base64-decodes the payload. Returns the claims without checking the signature. | None. Anyone can construct a JWT with arbitrary claims. |
jwt.verify(token, key) | Verifies the signature using the provided key, then returns the claims. | Authenticated. The claims were issued by the holder of the signing key. |
The jsonwebtoken npm package exports both functions at the top level. The naming is confusing — decode sounds like it is doing useful cryptographic work, when it is doing base64 parsing only. This naming confusion is why 89% of repositories using jsonwebtoken have at least one violation in our bulk scan.
Broader Context
From our bulk scan of 6,283 TypeScript repos: 129 repos use jsonwebtoken. 115 have violations (89%). 310 total.
The dominant pattern: calling jwt.decode() when jwt.verify() is needed. The nango finding is a nuanced version of this — the existing uses are defensible, but the code does not communicate why, and the shared utility's name actively hides the risk.
Source Data
- Scan tool: Nark (Profile:
jsonwebtoken) - Repository: NangoHQ/nango on GitHub
- Scan date: April 2026
- Total jsonwebtoken violations in repo: 6
- Total violations across all packages: 320 errors, 48 warnings
- Bulk scan aggregate: 6,283 repos scanned, 129 use jsonwebtoken, 89% have at least one unguarded
decode()call