Security Basics
6 min
A JWT can look completely legitimate while granting an attacker access to another customer’s data. That is why teams must validate JWT signatures on every protected request, not merely decode the token and inspect its claims. A token is only trustworthy when your server has verified that an approved issuer signed it and that it is valid for the route being accessed.
This sounds basic, but it is a frequent weakness in rapidly built SaaS products. AI-generated middleware, copied authentication snippets and improvised API gateways often decode a JWT, check for a user ID, then continue. That pattern can turn a private endpoint into an account takeover or data exposure route.
What a JWT signature actually proves
A JSON Web Token has three dot-separated sections: a header, a payload and a signature. The header identifies the signing algorithm and may include a key ID. The payload holds claims such as the user ID, issuer, audience and expiry. The signature proves that the header and payload have not been altered since the trusted identity provider issued the token.
The payload is encoded, not encrypted. Anyone holding a token can decode it. Seeing a value such as sub, role or organisation_id does not prove that value is genuine. An attacker can create a new token with an administrator role, a different customer ID or a far-future expiry. If your API accepts the payload without checking the signature, it accepts the attacker’s version of reality.
Signature validation answers one narrow but essential question: was this exact token produced by a signing key we trust? It does not, by itself, prove that the user should be able to perform the requested action. Your application still needs proper authorisation checks, including ownership checks and database policies.
How to validate JWT signatures correctly
Validation should happen at the API boundary, before business logic reads claims or fetches data. In a typical application, that means middleware attached to protected routes, a server-side API gateway, or a backend authentication helper that every request must pass through.
Use a maintained JWT library for your language and configure it explicitly. Avoid custom cryptographic code and avoid helpers that only decode tokens. Your validator should reject a token unless all relevant checks succeed.
Pin the expected algorithm
Your server should know which algorithm it expects before it processes the token. For example, if your identity provider signs access tokens with RS256, configure the validator to permit RS256 only. Do not let the token header select any algorithm the library happens to support.
This prevents algorithm-confusion failures. A badly configured verifier may accept a token signed with an unintended symmetric algorithm, or treat a public key as a shared secret. It should also reject unsigned tokens using alg: none without exception.
Asymmetric algorithms such as RS256 or ES256 are common when an external identity provider issues tokens. Your API verifies tokens with a public key, while the provider keeps the private signing key. Symmetric HS256 can be appropriate for a tightly controlled internal service, but every service that validates tokens must then possess the signing secret. That increases the impact if one environment, log file or repository is exposed.
Verify the issuer, audience and lifetime
A valid signature is necessary, but it is not the entire decision. Check the iss claim against your exact expected issuer URL. This stops your API accepting a properly signed token from a different tenant, development environment or unrelated identity system.
Check the aud claim as well. A token minted for one API should not automatically work against another service merely because both use the same authentication provider. This matters when a product has separate admin, customer and internal APIs.
Enforce exp and, where used, nbf. Expired tokens should fail. Tokens that are not yet valid should fail. Allow a small clock-skew tolerance only where necessary, typically seconds rather than minutes. Large leeway windows make replayed or stale tokens useful for longer than intended.
If your provider supplies an authorised party claim, tenant claim or client ID claim, verify it when it has security meaning. The rule is simple: check the claims that distinguish a token intended for your route from one that merely happens to be correctly signed.
Fetch verification keys safely
For RS256 and similar algorithms, validators commonly retrieve public keys from a JSON Web Key Set, or JWKS, endpoint. The token header’s kid identifies which public key to use, allowing the provider to rotate keys without breaking live sessions.
Treat kid as an identifier, not a URL or a file path. Look it up only in the trusted JWKS you have configured for the known issuer. Do not follow jku, x5u or other header-provided URLs. Those fields can be abused to direct a careless validator towards an attacker-controlled key.
Cache approved public keys for a sensible period, then refresh when an unknown kid appears. Key rotation is normal. Re-fetching the JWKS on every request is not necessary and can create an availability problem during traffic spikes. Equally, caching forever can cause avoidable outages when the provider rotates keys.
Keep authentication and authorisation separate
After signature and claim validation, your code has established who presented the token. It has not established that they may read record 123, issue a refund, change a subscription or invite another user.
A common insecure route looks like this: the API reads user_id or organisation_id from the request body and uses it in a database query. Even with a valid JWT, a logged-in user can replace that value with another customer’s identifier. This is an insecure direct object reference, often called IDOR.
Instead, derive the actor identity from the validated token and check the requested object against that identity. For multi-tenant products, verify that the object belongs to the user’s organisation and that the user has the required role. At the database layer, enforce the same boundary with row-level security where your stack supports it.
For Supabase applications, that means enabling and testing RLS on exposed tables, writing policies based on authenticated claims, and keeping the service-role key on trusted server infrastructure only. A correctly validated JWT will not compensate for disabled RLS or a browser bundle that exposes a service-role credential.
Failure patterns worth checking in a live application
During an application security review, JWT issues tend to appear in a few predictable places: API middleware, serverless functions, webhook handlers, admin routes and frontend-to-backend proxy code. The vulnerable code may be only a handful of lines.
Watch for decode() or base64 parsing where a verifier should be used. Review code paths that accept an Authorization header but never call a signature verification function. Check whether token verification errors are caught and then ignored so that the request continues with an empty or fallback user object.
Also examine environment separation. A production API should not accept tokens from a staging issuer, and a preview deployment should not share powerful production signing secrets. If a team uses an HS256 secret, verify that it is stored in server-only configuration, has not been committed to Git history and is not exposed through client-side environment variables.
Logging deserves care too. Authentication failures should be observable, but full JWTs should not be written to application logs, error-tracking tools or support tickets. A bearer token can be replayed until it expires.
Test the verifier as an attacker would
A passing login test does not prove your JWT controls are sound. Add negative tests that send a token with a modified payload, an invalid signature, the wrong issuer, the wrong audience and an expired timestamp. Every protected route should return an authentication failure before it touches data or triggers side effects.
Test a valid token for User A against User B’s records as well. That test moves beyond authentication into the authorisation checks that prevent IDOR. For payment, deletion and administrative actions, confirm that a customer token cannot reach staff-only functionality even if a role field is altered in an unsigned or incorrectly verified token.
Manual review matters because automated scanners can spot familiar JWT library issues but cannot always tell whether a decoded claim controls a sensitive database query, an RLS policy is too broad, or a fallback route bypasses middleware. HollowByte reviews those connections across the live app and repository, then maps findings to the affected files and the required fix.
JWT validation should be boring infrastructure: one trusted verifier, strict configuration and no route-level shortcuts. When it is treated that way, a forged token becomes a rejected request rather than the start of a customer data incident.
Back to blog
