User Authentication System: Stateless Authentication with JWT
2025年3月31日Elecmonkey
This article was translated by AI and has not been manually reviewed.
Why JWT
JWT (JSON Web Token) is a user authentication standard. It allows a server to authenticate user login, maintain login state, and handle expiration without storing login state.
In the traditional login model, after the username and password are verified, the server usually generates a random string as a Token, stores it itself, and sends one copy to the client. On subsequent visits, the client carries this Token every time. The server compares it with the database. If the comparison succeeds, the user is considered authorized to access resources; otherwise, they are not. This method gives the server absolute control over the global login state of the entire application, making it easy to add and revoke login states. But the cost is that the login system itself consumes huge server resources. Every login is a hearty database operation.
The advantage of JWT is that after issuance, the server does not need to store it and fully hands it over to the client for storage and management. Cryptographic verification ensures that the information submitted by the client has not been tampered with. For a Web application using JWT as the authentication mechanism, the server does not know which users are currently in a valid login state. The information stored by the client contains the login state. We can say JWT is stateless and self-contained.
JWT Structure and Generation Principle
A complete login function relies on the JWT token issued by the server: a string generated by the server. JWT consists of three parts separated by dots (.):
- Header
- Payload
- Signature
When the server verifies that the username and password are both correct and login succeeds, it starts issuing the JWT. The Header and Payload store information in Base64 plaintext. The Header only briefly declares the token and signature algorithm:
{
"alg": "HS256",
"typ": "JWT"
}
The Payload part can theoretically store any information you need or want to store on the client. However, because Base64 is plaintext encoding and has no encryption, passwords and other sensitive or private information should not be stored. Generally speaking, a public unique user identifier is necessary for login functionality. You can also put some public and frequently used client-side data such as nickname, profile signature, and avatar url. Client-side Javascript can directly use it without frequently fetching these things from the server.
As for how to write the Payload part, the JWT standard RFC 7591 Section 4.1 provides recommended field names. But this is only a recommended standard. In fact, it can only be a recommended standard because implementing your own JWT tool is really too easy. I tend to call login/authentication processes with roughly the same principle JWT.
For example, a possible Payload:
{
"sub": "userid_12345",
"iat": 1743424700,
"exp": 1743511100,
"avatar": "a.png",
"role": ["editor", "administrator"]
}
After preparing the plaintext Header and Payload, we convert them into Base64 form. Common languages and tools for Web development almost all have related built-in functionality:
const jsonHeaderString = `{"alg":"HS256","typ":"JWT"}`;
const jsonPayloadString = `{"sub":"userid_12345","iat":1743424700,"exp":1743511100,"avatar":"a.png","role": ["editor","administrator"]}`;
const base64HeaderString = btoa(jsonHeaderString);
const base64PayloadString = btoa(jsonPayloadString);
console.log(base64HeaderString);
console.log(base64PayloadString);
Output:
# base64HeaderString
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
# jsonPayloadString
eyJzdWIiOiJ1c2VyaWRfMTIzNDUiLCJpYXQiOjE3NDM0MjQ3MDAsImV4cCI6MTc0MzUxMTEwMCwiYXZhdGFyIjoiYS5wbmciLCJyb2xlIjogWyJlZGl0b3IiLCJhZG1pbmlzdHJhdG9yIl19
The client cannot freely forge these first two parts of information. How does the server know whether the JWT submitted by the client has been forged or tampered with? This is the role of the third part, Signature. Generating the Signature requires a key that only the server knows forever. In server-side Web applications, it is usually controlled by environment variables.
Concatenate the Base64 encodings of the first two parts and the server-side key together (RFC7591 has a standard, but when implementing it yourself, how you concatenate does not actually matter much), then Hash it. We all know the mathematical characteristics of Hash digest algorithms: deterministic input always produces deterministic output, it is hard to construct another input with the same output, and you cannot reverse the input from the output. So as long as the previous Header, Payload, and key are kneaded together and hashed, it can be issued to the client. When the client sends the same thing with the next request, the server verifies it using the same rules and can know whether it has been tampered with. As long as the server key has not leaked, the client cannot forge a non-server-issued JWT that passes server validation.
const secretKey = "江山代有才人出各领风骚数百年"; // 密钥
const unsignedToken = `${base64HeaderString}.${base64PayloadString}`;
function generateSignature(message, secret) {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(message);
return crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
).then(key => {
return crypto.subtle.sign('HMAC', key, messageData);
}).then(signature => {
const signatureArray = Array.from(new Uint8Array(signature));
const signatureBase64 = btoa(String.fromCharCode(...signatureArray));
return signatureBase64;
});
}
const signature = (await generateSignature(unsignedToken, secretKey)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
console.log(signature);
Output:
# Signature
-ZjUVrcynB1FLMw8Opdlh-y1dAyghL2BkOY3MOzMAMQ
Put these three together and you have the complete JWT token.
# JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyaWRfMTIzNDUiLCJpYXQiOjE3NDM0MjQ3MDAsImV4cCI6MTc0MzUxMTEwMCwiYXZhdGFyIjoiYS5wbmciLCJyb2xlIjogWyJlZGl0b3IiLCJhZG1pbmlzdHJhdG9yIl19.-ZjUVrcynB1FLMw8Opdlh-y1dAyghL2BkOY3MOzMAMQ
Of course, the "expiration time" (exp) can be set in the Payload and is verified in the same place. If the server finds that the JWT is already outside the validity period specified in the Payload, it can directly determine that the JWT is invalid without doing the later verification.

JWT.io has a small online tool that can help you generate JWTs or verify whether a JWT is correct, which is convenient for development and debugging.
The overall workflow of JWT is as follows:
sequenceDiagram
participant Client
participant Server
Note over Client: 登录请求
Client->>Server: 发送用户名/密码
Server-->>Client: 验证通过,返回JWT
Note over Client: 携带JWT访问资源
Client->>Server: 请求API (Header: Authorization: Bearer <JWT>)
Server->>Server: 1. 解析JWT头部和签名
Server->>Server: 2. 验证签名有效性
Server->>Server: 3. 检查有效期和声明(claims)
alt 验证成功
Server-->>Client: 返回请求的资源数据
else 验证失败
Server-->>Client: 返回401未授权
end
Shortcomings of JWT
Comparison between JWT and traditional Session authentication:
| Feature | JWT | Session |
|---|---|---|
| Storage location | Client | Server |
| Scalability | High (stateless) | Low (requires shared session storage) |
| Security | Depends on signature algorithm | Depends on session ID |
| Performance | Verification is performed on the server, no database query needed | Requires querying session storage |
| Size | Larger (contains all information) | Smaller (only an identifier) |
| Cross-origin | Easy to implement | Requires special configuration |
| Logout | Complex (requires extra mechanism) | Simple (directly delete session) |
You can actually find that JWT's most important flaw is that the server has no management capability over sessions at all. The following common requirements are hard to implement with JWT:
- Force a certain session offline
- Limit a single user to only one login session
If you really want to implement these, you still have to introduce a database to do tricks. Optional solutions include adding verification fields to the user's MySQL table or introducing Redis to cache session information. In that case, even with JWT, every login still requires database operations. When you have such requirements, whether to keep JWT and introduce a database mechanism on top of it, or simply stop making things so complicated and return to the traditional Session Token model, depends on the specific business scenario.
XSS and CSRF Attack Prevention
The threat of XSS (Cross-Site Scripting) to JWT is mainly that attackers may obtain JWTs stored in localStorage or cookies by injecting malicious scripts. CSRF (Cross-Site Request Forgery) may use cookies automatically included in request headers to submit destructive operations that do not originate from the user's intention.
One solution is to store JWT in an HttpOnly Cookie so JavaScript cannot access the token. However, if you do this, it is best to use a reverse proxy such as Nginx to proxy frontend and backend services under the same domain name. Otherwise, you may again be tortured by various cross-origin issues. Since JWT is already stored in Cookie, CSRF prevention is relatively simple: setting the Cookie SameSite attribute to Strict or Lax basically avoids problems. However, with HttpOnly Cookie, the frontend cannot access information in the JWT Payload. You can choose to simply not put too much useful information in the Payload, and let frontend and backend work harder and Fetch data aggressively.
For scenarios where the frontend needs to use Payload data, you still have to honestly store it somewhere JavaScript can access, such as localStorage. Then you need to pay attention to XSS. Use CSP (Content Security Policy) to restrict executable script sources in the page, and strictly validate and escape all user input.
Of course, for Web applications, silent renewal is also an important topic, but it is outside the scope of this article. If you implement a mechanism of "using a refresh token to obtain a new access token", where the refresh token has a longer validity period than the access token, then the former can be stored in an HttpOnly Cookie and the latter in localStorage accessible to Javascript. If the silent refresh mechanism is configured truly "silently", you can set the latter's validity period very short: fifteen minutes, or ten minutes. This way, even if XSS occurs, the stolen access token only brings a very short attack window.