Building Production-Ready JWT Authentication in Node.js APIs
How to hash passwords, issue short-lived JWTs, add refresh tokens and revocation, separate authorization, resist brute force and test that auth fails correctly.
This article is published in English.
Sources: JWT Authentication in Node.js: What I Learned Building Secure APIs
On a requirements document, authentication fits in one line: a user signs in with an email and password, and the API grants access. The moment you implement it, that line turns into a dozen questions. Where do passwords live and how are they checked? How does the API recognize the caller on each request? What happens when a token expires, how do refresh tokens fit in, and what should the system do after a string of failed sign-ins? Underneath all of them sits the question that matters most: how do you stop the login system itself from becoming the weakest part of the application?
This guide works through those questions for a Node.js and TypeScript API backed by PostgreSQL, treating authentication not as a single endpoint but as a security layer that wraps the whole application. By the end you will have a clear model of each layer, the pitfalls at each one, and a checklist for deciding when the system is actually ready for production.
Store password hashes, never passwords
The first rule is that a password is not ordinary data. It must never be persisted as plain text. A user record like the one below is unacceptable, because a single database leak would expose every user's password, and since people reuse passwords, it would expose their accounts elsewhere too.
email: sachin@example.com
password: MyPassword123
Instead, run each password through a deliberately slow password-hashing algorithm before saving it. With the bcrypt package, you import the library:
import bcrypt from "bcrypt";
and hash the password during registration. The second argument, 12, is the cost factor: each increment doubles the work required, which makes offline guessing against a stolen hash table far more expensive while staying fast enough for a single login.
const passwordHash = await bcrypt.hash(password, 12);
At sign-in, nothing is decrypted. The server takes the password the user just submitted and asks bcrypt whether it matches the stored hash. bcrypt.compare reads the salt and cost embedded in the hash, repeats the computation, and returns a boolean.
const isValid = await bcrypt.compare(
password,
user.passwordHash
);
The key distinction is that passwords are hashed, not encrypted for later retrieval. Once registration is complete, your application should never need, or be able, to recover the original password. Two practical notes: bcrypt only considers the first 72 bytes of input, so very long passphrases are silently truncated, and algorithms such as Argon2 are a common alternative if you want a memory-hard option.
Treat login as a workflow, not a query
A typical sign-in endpoint follows a chain of steps, from receiving the request through validation, lookup, comparison and token creation:
Client
↓
POST /api/auth/login
↓
Validate input
↓
Find user
↓
Compare password
↓
Create authentication tokens
↓
Return authentication response
What matters is that any step can fail, and each failure needs a deliberate response. The email may be malformed. No user may match it. The password may be wrong. The account may be locked. The database may be unreachable. It is a security-sensitive workflow, and each branch should be designed, logged appropriately and tested.
What a JWT is, and what it is not
JSON Web Tokens are a common way to represent an authenticated identity in an API. After a successful sign-in, the server issues a signed token describing the user. A minimal payload might carry only a subject and a role; sub is the standard claim for the user identifier.
{
"sub": "user-id",
"role": "user"
}
The payload is encoded, not encrypted, so anyone holding the token can read it. Keep it to identifiers and coarse claims, never secrets or personal data. On subsequent requests, the client includes the token in the Authorization header using the Bearer scheme:
Authorization: Bearer <access-token>
Before serving any protected resource, the backend verifies the token's signature and expiry. The overall sequence looks like this:
Login
↓
Credentials verified
↓
Access token issued
↓
Client sends token
↓
Server verifies token
↓
Protected API is allowed
The critical lesson is that a JWT does not make an application secure by itself. Security depends just as much on how the token is created, where the client stores it, how strictly it is validated (including pinning the accepted signing algorithm), when it expires, whether it can be revoked, and how it travels over the network (always over HTTPS).
Keep access tokens short-lived
Access tokens should not last indefinitely. If a token valid for months leaks through a log file or a compromised device, an attacker can use it for months.
The example below uses jsonwebtoken to sign a token containing the user's id and role with a secret read from the environment, and sets expiresIn to fifteen minutes. The ! after process.env.JWT_SECRET is TypeScript's non-null assertion; in production code it is safer to validate at startup that the variable exists than to assert it at every call site.
const accessToken = jwt.sign(
{
sub: user.id,
role: user.role
},
process.env.JWT_SECRET!,
{
expiresIn: "15m"
}
);
The right lifetime depends on your security requirements, but the principle holds everywhere: limit how long any credential that unlocks protected resources remains usable. A short window means a stolen token becomes worthless quickly.
Refresh tokens solve the usability problem
If access tokens expire every fifteen minutes, users would be forced to sign in again constantly, which no one tolerates. Refresh tokens address that. At login the client receives two credentials: a short-lived access token used on API calls, and a longer-lived refresh token whose only purpose is obtaining a new access token when the old one expires.
┌─────────────────┐
│ Login │
└────────┬────────┘
↓
┌─────────────────┐
│ Access Token │
│ Short Lifetime │
└────────┬────────┘
│
API Requests
│
↓
Token expires
│
↓
┌─────────────────┐
│ Refresh Token │
│ Longer Lifetime │
└────────┬────────┘
↓
New Access Token
This split lets access tokens stay short while sessions still feel continuous. It also introduces a new responsibility, because the refresh token is now the more valuable credential. It must be stored carefully (commonly in an httpOnly, Secure cookie on the web rather than in JavaScript-accessible storage), sent only to the refresh endpoint, and validated strictly by the server. For a deeper design discussion, see our guide to refresh token strategy for Node.js authentication.
Plan for revocation
A property of self-contained tokens that surprises many teams is that they stay valid after something changes on the server. Consider a user who logs out, an administrator who disables an account, a refresh token that is known to be compromised, or a security incident that requires every session to be ended. If the design has no revocation mechanism, deleting or updating a database row does nothing to a token that has already been issued; it will keep working until it expires.
For that reason, many authentication systems keep server-side records of refresh tokens or sessions, often one per device, associated with each user:
User
├── Refresh Token A
├── Refresh Token B
└── Refresh Token C
With that record in place, the server can mark individual tokens as revoked, end all sessions for a user, and refuse to mint new access tokens from a revoked refresh token. This reveals an honest trade-off: JWTs reduce how much session state the server must keep, but secure authentication usually still requires some server-side state. Short access-token lifetimes plus revocable refresh tokens are a common way to balance the two.
Enforce authentication in middleware
Repeating token checks in every controller is error-prone; one forgotten check leaves an endpoint open. Middleware centralizes the logic. In Express, a protected route simply places an authenticate function before its controller:
app.get(
"/api/profile",
authenticate,
profileController
);
That middleware has a small, well-defined job:
- Read the
Authorizationheader. - Extract the token from the Bearer value.
- Verify the signature and expiry.
- Determine which user the token represents.
- Attach that identity to the request object for later handlers.
- Reject the request with an error if any step fails.
The resulting control flow is easy to reason about: an invalid or missing token produces a 401, and only a valid one reaches the controller.
Request
↓
Authentication Middleware
↓
Token valid?
↙ ↘
No Yes
↓ ↓
401 Controller
↓
Response
Authentication and authorization are different checks
These two words are often used interchangeably, but they answer different questions. Authentication asks who the caller is. Authorization asks what that caller is allowed to do. Our article on where authentication and authorization belong in your code explores the boundary in more depth.
Imagine an application with three roles:
Admin
Staff
User
Members of all three roles can be fully authenticated, yet their permissions differ sharply. A regular user might be permitted to read their profile, list issues and create new ones:
GET /api/profile
GET /api/issues
POST /api/issues
An administrator could additionally be allowed to modify user accounts and delete issues:
PATCH /api/users/:id
DELETE /api/issues/:id
Holding a valid JWT only proves identity; it does not grant permission for every operation. A secure backend needs both layers, typically an authentication middleware followed by role or permission checks per route, and ideally ownership checks as well, so a user can edit their own issue but not someone else's.
Protect against repeated failed logins
Authentication design must account for attackers, not only legitimate users. An automated script can submit password guesses indefinitely unless something stops it. The simplest defense is a counter that triggers a temporary lock after several consecutive failures:
Attempt 1 → Failed
Attempt 2 → Failed
Attempt 3 → Failed
Attempt 4 → Failed
Attempt 5 → Failed
↓
Temporary lock
In practice, you would combine several measures depending on your requirements:
- Rate limiting requests to the login endpoint.
- Temporary account lockouts.
- Progressively increasing delays after each failure.
- Monitoring for suspicious patterns.
- Protections keyed on IP address.
- Alerts for security-relevant events.
Choose carefully, because a defense can become a denial-of-service vector. If anyone can lock an account just by typing its email wrong five times, attackers can lock real users out on purpose. The objective is not merely to block after failed attempts, but to make automated attacks expensive while legitimate users can still get in, for example by combining per-account delays with per-IP rate limits rather than hard lockouts alone.
Never trust identity supplied by the client
A dangerous pattern is believing data just because the frontend sent it. Suppose a request body declares the caller's user id and role:
{
"userId": "123",
"role": "admin"
}
Nothing prevents a malicious client from editing those fields. The backend must never assume they are true. Identity has to come from credentials the server verified itself, which means the user id and role are read from the validated token (or from the database using the token's subject), and permission checks are based on that:
Request
↓
Verify JWT
↓
Read authenticated user ID
↓
Check permissions
↓
Access resource
The server is the final authority. This principle extends well beyond authentication: the client can improve the experience by hiding buttons a user cannot use, but it can never be relied on to enforce security rules.
Avoid leaking information in error messages
Security reaches into details like error wording. Compare a response that says the email does not exist:
Email does not exist.
with one that stays deliberately vague:
Invalid email or password.
The first tells an attacker which emails are registered, which allows account enumeration and makes targeted password guessing easier. The second gives a legitimate user enough to try again without confirming anything. Keep in mind that enumeration can also happen through response timing or through registration and password-reset endpoints, so the same care applies there.
The broader rule covers database errors and unhandled exceptions too. Clients need an error they can act on; they do not need a stack trace, a SQL message or internal identifiers.
Keep secrets out of source code
Authentication depends on secrets such as a signing key, a database connection string and third-party API keys:
JWT_SECRET
DATABASE_URL
API_KEY
These must never be committed to a repository. A hardcoded key like this is a liability the moment anyone else can read the code:
const secret = "my-super-secret-key";
Reading it from the environment is the minimum improvement:
const secret = process.env.JWT_SECRET;
In production, go further and supply secrets through a proper configuration or secret-management system. This matters most for public repositories, where one accidentally committed key can escalate into a serious incident almost immediately; if it happens, rotate the secret rather than just deleting the commit.
Test that authentication fails correctly
Manual testing is a poor fit for authentication, because the important cases are the ones that should not succeed. A basic automated suite should cover both successful and rejected paths across each part of the flow.
Registration
Valid sign-ups succeed, while malformed emails, invalid input and duplicate accounts are rejected:
Valid registration → Success
Invalid email → Rejected
Weak/invalid input → Rejected
Duplicate account → Rejected
Login
Correct credentials succeed; wrong passwords, unknown accounts and locked accounts are refused:
Correct password → Success
Wrong password → Rejected
Unknown account → Rejected
Locked account → Rejected
Protected routes
Missing, invalid and expired tokens each produce 401, a valid token is allowed through, and a valid token without the required role produces 403. Note the distinction: 401 means "we do not know who you are," while 403 means "we know who you are, and the answer is no."
No token → 401
Invalid token → 401
Expired token → 401
Valid token → Allowed
Valid token + insufficient role → 403
Tests like these do more than confirm authentication works. They prove it fails in the right way, which is where most real vulnerabilities hide.
A layered model of authentication
It helps to picture authentication as a stack of separate layers, each with one responsibility, from credentials through password verification, token issuance, token validation and authorization, to the protected resource itself:
┌─────────────────────────────┐
│ User Credentials │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Password Verification │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Token Issuance │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Token Validation │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Authorization │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Protected Resource │
└─────────────────────────────┘
Around the whole stack sits a cross-cutting layer: monitoring, logging, rate limiting, secure configuration and testing.
Production-readiness checklist
Before calling an authentication system complete, confirm that:
- Passwords are hashed with a strong, slow algorithm.
- Access tokens have an appropriate, short expiration.
- Refresh tokens are stored, transported and validated securely.
- Every protected route validates authentication.
- Authorization is enforced as a separate step from authentication.
- Failed login attempts are throttled without enabling lockout abuse.
- Secrets are kept out of code and managed securely.
- Logs do not contain passwords, tokens or other sensitive values.
- Error responses reveal no unnecessary internal detail.
- Authentication flows are covered by automated tests.
- Token and session invalidation has been designed, not deferred.
- Production configuration has been reviewed.
This list is a far better gate than asking whether login works.
Key takeaways
Authentication is not something to attach at the end of a project. It shapes database design, API structure, middleware, frontend behavior, error handling, logging, testing and deployment, and above all, security. A login endpoint takes a few lines; a system that behaves safely when real users, attackers, expired tokens, malformed requests and production failures all show up is a much larger engineering problem.
The natural next area to study is refresh token security in depth: where refresh tokens should be stored, whether and how to rotate them, how to detect reuse of an old token, what revocation looks like in practice, and how logout should work across multiple devices. Each of those decisions follows the same principle as everything above: security holds up when it is part of every design choice, not a layer added afterwards.