# How the RealWorld API Handles JWT Authentication and Token Refresh Mechanisms

> Discover how the RealWorld API secures JWT authentication with long-lived tokens in HTTP-only cookies, simplifying token refresh for robust security.

- Repository: [Thinkster/realworld](https://github.com/gothinkster/realworld)
- Tags: deep-dive
- Published: 2026-02-28

---

**The RealWorld API implements JWT authentication by issuing long-lived tokens (60 days) stored in secure HTTP-only cookies, intentionally omitting a separate refresh token mechanism to maintain architectural simplicity.**

The gothinkster/realworld repository provides a full-stack "Medium clone" specification that demonstrates production-grade application patterns. In the `apps/api` backend package, the RealWorld API handles JWT authentication using signed tokens persisted via cookies, with verification middleware protecting sensitive endpoints.

## JWT Token Generation and Configuration

### The useGenerateToken Utility

In [`apps/api/server/utils/generate-token.ts`](https://github.com/gothinkster/realworld/blob/main/apps/api/server/utils/generate-token.ts), the `useGenerateToken` function creates signed JWTs using the `jsonwebtoken` library. The implementation signs a payload containing the user ID with a secret from `process.env.JWT_SECRET` and sets a **60-day expiration** (`expiresIn: '60d'`).

```typescript
// apps/api/server/utils/generate-token.ts
import jwt from 'jsonwebtoken';

export const useGenerateToken = (id: number): string =>
  jwt.sign({ user: { id } }, process.env.JWT_SECRET, {
    expiresIn: '60d',
  });

```

This long expiration window eliminates the need for a separate refresh token flow, as the single token remains valid for the entire user session duration.

## Authentication Flow: Login, Signup, and Logout

### Issuing Tokens on Authentication

After successful credential validation, both the signup and login routes invoke `useGenerateToken` and store the result in an **HTTP-only, secure cookie** named `auth_token`. The cookie configuration uses `sameSite: 'none'` to support cross-origin requests in the demo environment.

In [`apps/api/server/routes/api/v2/auth/login.post.ts`](https://github.com/gothinkster/realworld/blob/main/apps/api/server/routes/api/v2/auth/login.post.ts) (and similarly in [`signup.post.ts`](https://github.com/gothinkster/realworld/blob/main/signup.post.ts)):

```typescript
// apps/api/server/routes/api/v2/auth/login.post.ts
if (match) {
  setCookie(event, 'auth_token', useGenerateToken(user.id), {
    secure: true,
    httpOnly: true,
    sameSite: 'none',
  });
  // response payload …
}

```

### Terminating Sessions

The logout route in [`apps/api/server/routes/api/v2/auth/logout.post.ts`](https://github.com/gothinkster/realworld/blob/main/apps/api/server/routes/api/v2/auth/logout.post.ts) simply deletes the `auth_token` cookie, immediately invalidating the session on the client side.

```typescript
// apps/api/server/routes/api/v2/auth/logout.post.ts
export default defineEventHandler(async (event) => {
  deleteCookie(event, 'auth_token');
  return "Logged out";
});

```

## Token Verification and Protected Routes

### The useCheckAuth Middleware

The `useCheckAuth` function in [`apps/api/server/utils/auth.ts`](https://github.com/gothinkster/realworld/blob/main/apps/api/server/utils/auth.ts) validates incoming requests by checking either the `Authorization` header (supporting both `Token` and `Bearer` prefixes) or the `auth_token` cookie. It verifies the signature using `jwt.verify()` and attaches the decoded user object to `event.context.user`.

```typescript
// apps/api/server/utils/auth.ts
export const useCheckAuth = (mode: 'optional' | 'required') => (event) => {
  const header = getHeader(event, 'authorization');
  let token;

  if (header && (header.split(' ')[0] === 'Token' || header.split(' ')[0] === 'Bearer')) {
    token = header.split(' ')[1];
  }

  if (!token && mode === 'required') {
    throw createError({ status: 401, statusMessage: 'Unauthorized', message: 'Missing authentication token' });
  }

  if (token) {
    const verified = jwt.verify(token, process.env.JWT_SECRET);
    if (!verified) {
      throw createError({ status: 403, statusMessage: 'Unauthorized', message: 'Invalid authentication token' });
    }
    event.context.user = verified.user; // attach decoded user to request context
  }
};

```

### definePrivateEventHandler for Route Protection

The higher-order function `definePrivateEventHandler` in [`apps/api/server/auth-event-handler.ts`](https://github.com/gothinkster/realworld/blob/main/apps/api/server/auth-event-handler.ts) wraps route handlers to enforce authentication requirements. It accepts a `requireAuth` option (defaulting to `true`) and uses the same verification logic to extract the user ID before passing control to the protected handler.

```typescript
// apps/api/server/auth-event-handler.ts
export function definePrivateEventHandler<T>(
  handler: (event: H3Event, cxt: PrivateContext) => T,
  options: { requireAuth: boolean } = { requireAuth: true }
) {
  return defineEventHandler(async (event) => {
    // … token extraction & verification (same as above) …
    if (token) {
      const verified = jwt.verify(token, process.env.JWT_SECRET);
      return handler(event, { auth: { id: Number(verified.user.id) } });
    } else {
      return handler(event, { auth: null });
    }
  });
}

```

## Token Refresh Strategy in the RealWorld API

Unlike many production APIs that implement short-lived access tokens with long-lived refresh tokens, the RealWorld API deliberately avoids a separate refresh mechanism. The **60-day JWT lifespan** serves as both access and refresh credential, forcing the user to re-authenticate only after two months of inactivity or when explicitly logging out.

This design choice prioritizes simplicity for the "real-world" demonstration purpose. If stricter session control were required, developers could shorten the `expiresIn` value and add a dedicated refresh endpoint that validates a stored refresh token before issuing a new JWT.

## Summary

- **Token Creation**: The `useGenerateToken` utility in [`apps/api/server/utils/generate-token.ts`](https://github.com/gothinkster/realworld/blob/main/apps/api/server/utils/generate-token.ts) signs JWTs with a 60-day expiration using `process.env.JWT_SECRET`.
- **Cookie Storage**: Authentication endpoints store tokens in secure, HTTP-only `auth_token` cookies via `setCookie`, while logout clears them with `deleteCookie`.
- **Request Verification**: The `useCheckAuth` middleware and `definePrivateEventHandler` wrapper validate tokens from either the `Authorization` header or cookies, attaching the decoded `user.id` to the request context.
- **No Refresh Flow**: The API intentionally omits refresh tokens, relying on the long-lived JWT to maintain sessions until expiration or logout.

## Frequently Asked Questions

### Does the RealWorld API use refresh tokens?

No, the RealWorld API does not implement a separate refresh token mechanism. Instead, it issues JWTs with a 60-day expiration period stored in HTTP-only cookies, requiring users to log in again only after the token expires or when they manually log out.

### How long do JWT tokens last in the RealWorld API?

According to the source code in [`apps/api/server/utils/generate-token.ts`](https://github.com/gothinkster/realworld/blob/main/apps/api/server/utils/generate-token.ts), tokens are configured with `expiresIn: '60d'`, meaning they remain valid for 60 days from issuance unless deleted by a logout action.

### Where does the RealWorld API store JWT tokens?

The backend stores JWTs in a secure, HTTP-only, SameSite-None cookie named `auth_token`. While client-side test helpers may store tokens in `localStorage` for convenience, the actual API authentication relies on the cookie-based transmission handled automatically by Nitro's request processing.

### How does the RealWorld API verify JWT tokens on protected routes?

Protected routes use the `useCheckAuth` middleware or the `definePrivateEventHandler` wrapper to extract tokens from either the `Authorization` header (supporting `Token` or `Bearer` schemes) or the `auth_token` cookie. The system validates the signature using `jwt.verify()` against `process.env.JWT_SECRET` and throws 401 or 403 errors for missing or invalid tokens.