JWT from zero: what tokens do, how to secure them, and implementation with AI
Understand login, signed tokens and authorization through a B2B example. Complete JavaScript, Python and PHP code, negative tests and English AI prompts.

Why would I need this?
Imagine a mobile event application calling your billing API. After signing in, the user needs to make many requests without sending a password every time. A short-lived access token can carry a signed statement about that authenticated user. The API verifies it before considering the request. This is useful when several services need to verify access independently, or a mobile client calls an API.
A JWT is a format, not a complete authentication system. A server-side session with an opaque cookie is often simpler for a conventional website. Neither option removes the need for access rules. By the end of this guide, you will be able to explain the token flow, run three equivalent implementations, reject invalid tokens and ask AI for a change you can actually verify. The downloadable project is a local learning exercise; it has no real login endpoint.
1. Follow a request from login to invoice
The user proves their identity to your trusted login system. Only then does the server issue an access token. The client sends it over HTTPS, commonly in an Authorization: Bearer header. The API verifies its signature and claims, then checks whether this user may read this particular invoice.
Our fictional user user-7 belongs to Acme. A valid Acme token must still be refused when requesting another tenant’s invoice, and this example also refuses an invoice owned by another user in Acme. Authentication answers who you are; authorization answers what you may do. A hidden button or a difficult-to-guess invoice ID does not enforce either rule.
2. Understand the token and the security contract
The signed JWT used here has three base64url parts: header, payload and signature. Encoding is readable by the holder: do not put passwords, payment data or private documents in the payload. The signature detects tampering; it does not encrypt the content.
Our access profile uses HS256 and a server-generated key of at least 32 random bytes. iss identifies the issuer; aud names the receiving API; sub is a stable user ID; tenant identifies the organisation; iat and exp are integer Unix timestamps in seconds. The lifetime is at most five minutes. The custom typ nf-access+jwt distinguishes this exercise’s access token from other JWT uses. These are explicit application requirements, not claims that every JWT standard requires all these fields.
HS256 shares the signing secret with every verifier: each verifier could also mint tokens. In a larger service architecture, consider an asymmetric profile and trusted public keys/JWKS. Pin the expected algorithm and trusted key source; never let a token header choose an arbitrary URL or file.
3. Implement it in your language
The following complete module uses jose. issue() assumes the caller has already authenticated the user and resolved their tenant from trusted server data. verify() checks the cryptography and our access profile. canReadInvoice() applies the separate object-level rule. Generate the key once in a secret store for an actual service; generating a new key on every request would invalidate existing tokens. The test suite deliberately uses disposable random keys.
import {SignJWT,jwtVerify} from 'jose';
const issuer='https://auth.example.test', audience='billing-api';
function checkKey(key){if(!(key instanceof Uint8Array)||key.length<32)throw new Error('Use at least 32 random bytes');}
export async function issue(subject,tenant,key){
checkKey(key);
if(!subject||!tenant)throw new Error('Missing identity');
return new SignJWT({tenant}).setProtectedHeader({alg:'HS256',typ:'nf-access+jwt'})
.setSubject(subject).setIssuer(issuer).setAudience(audience)
.setIssuedAt().setExpirationTime('5m').sign(key);
}
export async function verify(token,key){
checkKey(key);
const {payload}=await jwtVerify(token,key,{
algorithms:['HS256'],typ:'nf-access+jwt',issuer,audience,
requiredClaims:['exp','iat','sub','tenant'],maxTokenAge:'5m',clockTolerance:0,
});
const now=Math.floor(Date.now()/1000);
if(typeof payload.sub!=='string'||!payload.sub||typeof payload.tenant!=='string'||!payload.tenant||
!Number.isInteger(payload.exp)||!Number.isInteger(payload.iat)||payload.iat>now||
payload.exp<=payload.iat||payload.exp-payload.iat>300)throw new Error('Invalid access profile');
return payload;
}
export function canReadInvoice(actor,invoice){
return !!actor?.sub&&!!actor?.tenant&&actor.tenant===invoice?.tenant&&actor.sub===invoice?.owner;
}"""Local HS256 access-token profile. Caller authenticates before issue()."""
import secrets, time, jwt
ISSUER, AUDIENCE = 'https://auth.example.test', 'billing-api'
def check_key(key):
if not isinstance(key, bytes) or len(key)<32: raise ValueError('Use 32 random bytes')
def issue(subject,tenant,key):
check_key(key)
if not subject or not tenant: raise ValueError('Missing identity')
now=int(time.time())
return jwt.encode({'sub':subject,'tenant':tenant,'iss':ISSUER,'aud':AUDIENCE,
'iat':now,'exp':now+300},key,algorithm='HS256',headers={'typ':'nf-access+jwt'})
def verify(token,key):
check_key(key)
data=jwt.decode_complete(token,key,algorithms=['HS256'],issuer=ISSUER,audience=AUDIENCE,
options={'require':['exp','iat','sub','tenant','iss','aud']})
p=data['payload']; now=int(time.time())
if (data['header'].get('typ')!='nf-access+jwt' or not isinstance(p['sub'],str)
or not p['sub'] or not isinstance(p['tenant'],str) or not p['tenant']
or type(p['iat']) is not int or type(p['exp']) is not int
or p['iat']>now or not 0<p['exp']-p['iat']<=300): raise ValueError('Invalid access profile')
return p
def can_read_invoice(actor,invoice):
return bool(actor.get('sub') and actor.get('tenant') and actor['sub']==invoice.get('owner') and actor['tenant']==invoice.get('tenant'))
if __name__=='__main__':
key=secrets.token_bytes(32);actor=verify(issue('user-7','acme',key),key)
print({'authenticated':True,'subject':actor['sub'],'sameTenantInvoice':can_read_invoice(actor,{'owner':'user-7','tenant':'acme'})})<?php
// Local HS256 profile. Issue tokens only after trusted server authentication.
require __DIR__.'/vendor/autoload.php';
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
function checkKey(string $key): void {
if(strlen($key)<32) throw new RuntimeException('Use 32 random bytes');
}
function issue(string $subject,string $tenant,string $key): string {
checkKey($key);
if($subject===''||$tenant==='') throw new RuntimeException('Missing identity');
$now=time();
return JWT::encode(['sub'=>$subject,'tenant'=>$tenant,'iss'=>'https://auth.example.test',
'aud'=>'billing-api','iat'=>$now,'exp'=>$now+300],$key,'HS256',null,['typ'=>'nf-access+jwt']);
}
function verify(string $token,string $key): object {
checkKey($key); $headers=new stdClass();
$p=JWT::decode($token,new Key($key,'HS256'),$headers); $now=time();
if(($headers->typ??null)!=='nf-access+jwt'||($p->iss??null)!=='https://auth.example.test'
||($p->aud??null)!=='billing-api'||!is_string($p->sub??null)||$p->sub===''
||!is_string($p->tenant??null)||$p->tenant===''
||!is_int($p->iat??null)||!is_int($p->exp??null)||$p->iat>$now||$p->exp<=$now
||$p->exp<=$p->iat||$p->exp-$p->iat>300) throw new RuntimeException('Invalid access profile');
return $p;
}
function canReadInvoice(object $actor,object $invoice): bool {
return !empty($actor->sub)&&!empty($actor->tenant)&&$actor->sub===($invoice->owner??null)&&$actor->tenant===($invoice->tenant??null);
}
if(realpath($_SERVER['SCRIPT_FILENAME']??'')===__FILE__){
$key=random_bytes(32);$actor=verify(issue('user-7','acme',$key),$key);
echo json_encode(['authenticated'=>true,'subject'=>$actor->sub,'sameTenantInvoice'=>canReadInvoice($actor,(object)['owner'=>'user-7','tenant'=>'acme'])]).PHP_EOL;
}4. Python: the same contract with PyJWT
PyJWT verifies the signature and registered claims when configured. Requiring a claim to exist is different from validating your own business meaning. We therefore also check non-empty identity fields, integer timestamps, the custom token type and the five-minute lifetime. Never use an unverified decode result as the authenticated actor.
5. PHP: validate application claims explicitly
PHP-JWT verifies the signature and time claims, but issuer, audience and our custom profile still need application checks. The wrapper in the PHP tab makes those requirements visible. The PHP example accepts exactly the string audience billing-api; broader integrations may use audience arrays and must implement their chosen contract deliberately. Do not silently remove a check because a different provider uses another shape.
6. Keep the token safe in a real application
A stolen bearer token can be replayed until expiry or revocation. Use HTTPS, redact Authorization headers from logs, and never put tokens in URLs. In a browser, an HttpOnly, Secure cookie can keep JavaScript from reading the token; choose SameSite for the actual flow and implement CSRF defenses for cookie-authenticated state-changing requests. A cookie does not fix XSS. Browser-readable storage, including localStorage, exposes tokens to injected scripts. Native apps should use the platform’s protected credential storage.
Short expiry limits exposure but does not implement logout. If immediate invalidation matters, use a server-side session/version check or a revocation mechanism. Refresh tokens need their own longer-lived storage, rotation, reuse detection and revocation design. Rotate signing keys with a controlled overlap and a trusted key registry. A user’s current organisation membership may change before exp: sensitive operations should consult current server-side authorization. These concerns are outside this tiny lab.
7. Run the project and read the result
Choose one language or compare all three. The ZIP includes lockfiles for JavaScript and PHP, tests and the complete modules. Installation needs the corresponding runtime and network access to package registries. There are no API keys or production credentials.
A successful run proves the stated local checks: a valid token works; changed signature, expired/missing claims, wrong issuer, audience or type, future issue time, excessive lifetime and weak key are refused. It also checks same-tenant ownership. JavaScript and PHP print 16 passed assertions; Python runs one test method with the equivalent cases. These are not HTTP integration tests.
# Choose ONE language; extract the ZIP into a new folder first.
# JavaScript / Node.js 22+
npm ci
npm test
# Python 3.9+ (macOS/Linux; Windows: .venv\Scripts\activate)
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt
python -m unittest test_python.py
python jwt_example.py
# PHP 8.1+ and Composer
composer install --no-plugins --no-scripts
php test_php.php
php jwt_example.php8. Ask AI to implement it, then demand evidence
Start with the implementation prompt below. It tells the assistant to inspect the existing framework before changing authentication. Replace the example issuer and audience deliberately for your own deployment, and keep signing secrets out of the conversation. Ask for a small diff and the test command, not a promise of security.
Act as a senior application engineer. First inspect the existing authentication
framework and explain whether an opaque server session or a JWT is appropriate.
Do not replace a working authentication system just to introduce JWTs.
If JWT access tokens are justified, implement the provided local access profile:
pinned HS256, at least 32 random key bytes, issuer https://auth.example.test,
audience billing-api, typ nf-access+jwt, non-empty sub and tenant, required
integer iat and exp, maximum lifetime 300 seconds and no future issued-at.
Use jose, PyJWT or PHP-JWT according to the actual project language.
Issue tokens only after trusted authentication. Enforce object ownership and
tenant membership in server authorization on every relevant request.
Never derive a signing key, accepted algorithm or tenant from request JSON.
Propose browser storage, CSRF protection, logout/revocation and key rotation
for this application before coding. Do not log tokens or secrets.
Add negative tests for bad signatures, expiration, missing claims, wrong issuer,
audience and token type, another tenant and a non-owner. Report real test output.
Stop before deployment.9. Example of a useful AI response
The example below illustrates the evidence you should expect. It is not a claim that an assistant inspected your application. A vague answer such as “JWT is now secure” leaves you unable to check anything. A useful answer connects a requirement to code, a negative test and an unresolved integration question. Then give a separate review prompt to a second reviewer or a new review pass.
Requirement: reject a token intended for another API.
Evidence: verify() supplies audience="billing-api".
Negative test: a correctly signed token with aud="other-api" is rejected.
Remaining gap: verify the real HTTP middleware invokes verify() on every route.
This example is an expected review format, not a certification.10. What you have built and what comes next
You now have a reproducible access-token profile in three languages and a clear separation between identity and permission. You can protect an API without treating a decoded payload as proof. Before using it for real users, connect the wrappers to your existing login and HTTP middleware, verify error handling, current permissions, cookie/CSRF behaviour, refresh/logout and key rotation with integration tests. The next useful step is a single protected endpoint with an owner case and a forbidden cross-tenant case.
Review this local JWT implementation independently. Produce a table with:
requirement, exact code location, positive test, negative test and remaining gap.
Explain the difference between decoding, signature verification and authorization.
Check key entropy, algorithm pinning, required claims, issuer/audience/type,
short lifetime, server-derived identity, browser storage and token revocation.
Report confirmed defects separately from unverified integration assumptions.
Use only synthetic tokens. Never ask me to paste production credentials.Official references and further reading
- IETF · JWT security practices
- jose · jwtVerify
- PyJWT · Usage
- PHP-JWT · Official repository
- OWASP · Session management
Take it into your project
Get the files, keep the guide, or give your coding assistant a ready-to-use brief.
Set it up with AI
Copy this English prompt into your coding assistant. It includes the article, available files and project setup steps.
View the setup prompt
Read this tutorial as reference material:
https://nethanelfitoussi.com/article/jwt-tokens-security-ai?lang=en
Goal: apply the documented workflow to my development project.
Read the existing project instructions, inspect the relevant files and explain the smallest useful setup.
Available starter files:
https://nethanelfitoussi.com/api/media/aba77d81-4cfd-4614-bca5-53015d625dff.zip
Download and inspect these files in a temporary folder before using them. Do not overwrite existing project instructions.
If I have not supplied a project folder, ask which development project to use. Do not invent a repository URL.
Additional project setup instructions:
Extract the supplied ZIP into a NEW folder. Read README.md before installing or running code.
Act as a senior application engineer. First inspect the existing authentication
framework and explain whether an opaque server session or a JWT is appropriate.
Do not replace a working authentication system just to introduce JWTs.
If JWT access tokens are justified, implement the provided local access profile:
pinned HS256, at least 32 random key bytes, issuer https://auth.example.test,
audience billing-api, typ nf-access+jwt, non-empty sub and tenant, required
integer iat and exp, maximum lifetime 300 seconds and no future issued-at.
Use jose, PyJWT or PHP-JWT according to the actual project language.
Issue tokens only after trusted authentication. Enforce object ownership and
tenant membership in server authorization on every relevant request.
Never derive a signing key, accepted algorithm or tenant from request JSON.
Propose browser storage, CSRF protection, logout/revocation and key rotation
for this application before coding. Do not log tokens or secrets.
Add negative tests for bad signatures, expiration, missing claims, wrong issuer,
audience and token type, another tenant and a non-owner. Report real test output.
Stop before deployment.
Review this local JWT implementation independently. Produce a table with:
requirement, exact code location, positive test, negative test and remaining gap.
Explain the difference between decoding, signature verification and authorization.
Check key entropy, algorithm pinning, required claims, issuer/audience/type,
short lifetime, server-derived identity, browser storage and token revocation.
Report confirmed defects separately from unverified integration assumptions.
Use only synthetic tokens. Never ask me to paste production credentials.
Adapt example paths, commands and acceptance criteria to this repository rather than assuming they match.
Give me a short plan, then make the agreed local changes and run the relevant checks. Report actual results and anything not verified. Stop before credentials, paid services, production changes, push, merge or deployment.

