DEV Community

EvvyTools
EvvyTools

Posted on

How to Implement JWT Authentication in a REST API Step by Step

JWT authentication for a REST API follows a clear sequence: the client sends credentials, the server issues a signed token, and the client sends that token on every subsequent request. Each step has a right way to do it and a few ways to get it wrong.

This guide covers the complete flow using Node.js examples, but the underlying logic applies to any backend language.

Step 1: Set Up Token Issuance on Login

The login endpoint receives credentials (username and password, OAuth code, or any other authentication factor) and returns a signed JWT on success.

const jwt = require('jsonwebtoken')

app.post('/api/login', async (req, res) => {
  const { email, password } = req.body

  const user = await db.users.findByEmail(email)
  if (!user || !await verifyPassword(password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' })
  }

  const payload = {
    sub: user.id,
    email: user.email,
    role: user.role
  }

  const token = jwt.sign(payload, process.env.JWT_SECRET, {
    expiresIn: '15m',
    issuer: 'https://api.yourapp.com',
    audience: 'https://api.yourapp.com'
  })

  res.json({ token })
})
Enter fullscreen mode Exit fullscreen mode

A few things to notice here. The sub is the user's database ID, not their email. The exp is set to 15 minutes. Both issuer and audience are set explicitly -- your verification step will check these against the same values.

The JWT secret comes from an environment variable, never hardcoded. For production, use an asymmetric key pair (RS256 or ES256) rather than a shared secret. Asymmetric keys let resource servers verify tokens using the public key without ever knowing the signing key.

Step 2: Create a Verification Middleware

Every protected route needs to check the token before the handler runs. A middleware function handles this cleanly.

function requireAuth(req, res, next) {
  const authHeader = req.headers.authorization

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' })
  }

  const token = authHeader.slice(7)

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],
      issuer: 'https://api.yourapp.com',
      audience: 'https://api.yourapp.com'
    })

    req.user = decoded
    next()
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' })
    }
    return res.status(401).json({ error: 'Invalid token' })
  }
}

// Apply to protected routes
app.get('/api/profile', requireAuth, (req, res) => {
  res.json({ userId: req.user.sub, email: req.user.email })
})
Enter fullscreen mode Exit fullscreen mode

Two important things here. The algorithms option explicitly restricts which algorithm is accepted. Do not let the token's own alg header determine this. The error handling distinguishes between expired tokens and invalid tokens -- clients need different behavior for each.

The JWT structure guide covers what alg, iss, aud, and the other fields mean structurally. The free JWT Decoder by EvvyTools is useful for inspecting the tokens your server is generating to confirm the payload contains what you expect.

Step 3: Implement Token Refresh

Short-lived access tokens require a refresh mechanism. The refresh token is a long-lived credential (hours or days) that the client uses to get a new access token when the current one expires. The refresh token must be stored securely and must be revocable.

// Issue both tokens at login
const accessToken = jwt.sign(accessPayload, process.env.JWT_SECRET, {
  expiresIn: '15m'
})

const refreshToken = jwt.sign(
  { sub: user.id, type: 'refresh' },
  process.env.REFRESH_SECRET,
  { expiresIn: '7d' }
)

// Store refresh token reference for revocation
await db.refreshTokens.create({
  userId: user.id,
  tokenHash: hashToken(refreshToken),
  expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
})

res.json({ accessToken, refreshToken })
Enter fullscreen mode Exit fullscreen mode

The refresh endpoint validates the refresh token, checks the stored reference, and issues a new access token:

app.post('/api/refresh', async (req, res) => {
  const { refreshToken } = req.body

  try {
    const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET)

    const stored = await db.refreshTokens.findByHash(hashToken(refreshToken))
    if (!stored || stored.revokedAt) {
      return res.status(401).json({ error: 'Invalid refresh token' })
    }

    const newAccessToken = jwt.sign(
      { sub: decoded.sub, ...await getUserClaims(decoded.sub) },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    )

    res.json({ accessToken: newAccessToken })
  } catch (err) {
    res.status(401).json({ error: 'Invalid refresh token' })
  }
})
Enter fullscreen mode Exit fullscreen mode

Terminal output showing a successful token verification response
Photo by Pixabay on Pexels

Step 4: Handle Logout and Revocation

When a user logs out, invalidate the refresh token by marking it revoked in storage. New refresh requests with the same token will fail the stored reference check.

For immediate access token revocation, you have two realistic options. Short expiry windows limit exposure: a 15-minute token expires soon enough that revocation is rarely urgent. Or maintain a small revocation list of jti values for active sessions, checked on every request.

Most applications use short expiry for access tokens and rely on refresh token revocation for forced logout. The user's active session ends within 15 minutes even if you cannot immediately invalidate every outstanding access token.

Step 5: Secure the Token in Transit and Storage

Access tokens belong in memory or in an HttpOnly cookie for browser clients. Storing a JWT in localStorage exposes it to XSS attacks -- any injected script on your page can read it.

// For browser clients, set as HttpOnly cookie
res.cookie('accessToken', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 15 * 60 * 1000
})
Enter fullscreen mode Exit fullscreen mode

All requests carrying JWTs must go over HTTPS. A JWT intercepted in transit gives the attacker the user's full session.

The JWT specification is defined in RFC 7519 on the IETF datatracker. The OWASP Cheat Sheet Series covers the broader security requirements for authentication implementations. The node-jsonwebtoken library on GitHub has complete documentation on options and error types.

Data center with network cabling organized along equipment racks
Photo by Brett Sayles on Pexels

Validating Your Implementation

Once your API is issuing and validating tokens, use the EvvyTools JWT Decoder to inspect a token your server generated. Verify the sub, iss, aud, and exp claims match your configuration. Compare a fresh token against a token that went through a refresh to confirm the payload structure is consistent.

EvvyTools hosts other developer utilities in the same place if you need them during implementation. JWT authentication follows a predictable pattern once you have the issuance, verification, and refresh logic in place. The security properties are not magic; they come from following the steps above consistently.

Step 6: Test the Full Auth Flow

Before considering the implementation complete, walk through the full cycle manually:

  1. POST to /api/login with valid credentials. Confirm you receive an accessToken and refreshToken. Decode the access token with a decoder and verify the sub, iss, aud, and exp fields match your configuration.

  2. Send a request to a protected route with the access token in the Authorization: Bearer header. Confirm a 200 response with the expected data.

  3. Wait for the access token to expire (or set a very short expiry for testing) and send the same protected request. Confirm a 401 response.

  4. POST to /api/refresh with the refresh token. Confirm you receive a new access token. Verify the new token has a later exp than the expired one.

  5. Attempt to use the old expired access token after refreshing. Confirm it is rejected with 401.

  6. POST to /api/logout (or simulate the revocation step). Confirm subsequent refresh requests with the revoked refresh token fail.

This sequence covers the main paths. Write it as an integration test so it runs automatically. The JJWT library and PyJWT both have test helpers for generating tokens with specific claims and expiry values, which makes writing these tests straightforward without needing a live authorization server.

Rotate your JWT secrets periodically. Establish a key rotation process before you go to production, not after. The node-jsonwebtoken library supports arrays of secrets to handle rotation windows, and most other libraries have equivalent support. When you rotate, issue new tokens using the new secret and configure a brief overlap period where both secrets are accepted.

Top comments (0)