← Back to DevBytes

Fastify Authentication: JWT, Sessions, and OAuth Integration

Understanding Authentication in Fastify

Authentication is the backbone of modern web applications. In Fastify, one of Node.js's fastest web frameworks, implementing robust authentication requires understanding the ecosystem of plugins, strategies, and patterns that integrate seamlessly with its high-performance architecture. This tutorial covers three essential authentication mechanisms — JWT (JSON Web Tokens), session-based auth, and OAuth integration — providing complete, production-ready code examples for each.

Fastify's plugin architecture and its encapsulation system make it uniquely suited for modular authentication setups. Unlike Express, Fastify enforces a clear separation of concerns through its plugin registration scope, which prevents cross-plugin pollution and enhances security. Before diving into code, let's understand why authentication matters in the Fastify context: Fastify's high throughput means authentication logic must be equally performant. A slow authentication middleware can bottleneck your entire application, negating Fastify's speed advantages.

Project Setup and Dependencies

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Create a new Fastify project and install the core dependencies we'll use across all authentication strategies:

mkdir fastify-auth-tutorial && cd fastify-auth-tutorial
npm init -y
npm install fastify @fastify/jwt @fastify/cookie @fastify/session @fastify/csrf-protection @fastify/formbody fastify-oauth2 bcrypt dotenv

Here's what each package does:

Part 1: JWT Authentication in Fastify

What Are JWTs and Why Use Them in Fastify?

JSON Web Tokens are compact, URL-safe tokens that carry claims (user data, permissions, expiry) as a self-contained JSON payload. They are cryptographically signed, allowing stateless authentication — the server doesn't need to store session data. This aligns perfectly with Fastify's design philosophy: minimal overhead, maximum throughput. JWTs shine in microservices architectures, API gateways, and mobile backends where session state is impractical.

Configuring @fastify/jwt

The @fastify/jwt plugin is the official Fastify module for JWT operations. It decorates the Fastify instance with jwt utilities and adds a verifyJWT decorator for route-level protection. Here's a complete setup with environment-based secrets:

// .env
JWT_SECRET=your-super-secret-key-at-least-32-chars
JWT_REFRESH_SECRET=your-refresh-secret-key
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
// src/config/jwt.config.js
import dotenv from 'dotenv';
dotenv.config();

export const jwtConfig = {
  secret: process.env.JWT_SECRET,
  refreshSecret: process.env.JWT_REFRESH_SECRET,
  signOptions: {
    expiresIn: process.env.JWT_EXPIRES_IN || '15m',
    issuer: 'fastify-auth-tutorial',
  },
  refreshSignOptions: {
    expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
    issuer: 'fastify-auth-tutorial',
  },
};

Building the JWT Auth Plugin

Fastify encourages encapsulating authentication logic in dedicated plugins. This prevents secret leakage across scopes and keeps your codebase organized. Below is a complete JWT authentication plugin that handles token signing, verification, refresh logic, and route decoration:

// src/plugins/jwt-auth.plugin.js
import fp from 'fastify-plugin';
import { jwtConfig } from '../config/jwt.config.js';

async function jwtAuthPlugin(fastify, opts) {
  // Register the JWT plugin
  await fastify.register(import('@fastify/jwt'), {
    secret: jwtConfig.secret,
    sign: jwtConfig.signOptions,
  });

  // Decorate Fastify with a custom authenticate method
  fastify.decorate('authenticate', async function (request, reply) {
    try {
      await request.jwtVerify();
    } catch (err) {
      reply.code(401).send({
        error: 'Unauthorized',
        message: 'Invalid or expired token',
        statusCode: 401,
      });
    }
  });

  // Decorate with role-based authorization helper
  fastify.decorate('authorize', function (requiredRole) {
    return async function (request, reply) {
      // authenticate runs first
      if (!request.user) {
        return reply.code(401).send({ error: 'Not authenticated' });
      }
      if (request.user.role !== requiredRole) {
        return reply.code(403).send({
          error: 'Forbidden',
          message: `Role '${requiredRole}' required`,
          statusCode: 403,
        });
      }
    };
  });

  // Generate access token
  fastify.decorate('signAccessToken', function (payload) {
    return fastify.jwt.sign(payload, {
      expiresIn: jwtConfig.signOptions.expiresIn,
    });
  });

  // Generate refresh token with longer expiry
  fastify.decorate('signRefreshToken', function (payload) {
    return fastify.jwt.sign(payload, {
      expiresIn: jwtConfig.refreshSignOptions.expiresIn,
      secret: jwtConfig.refreshSecret, // Different secret for refresh tokens
    });
  });

  // Verify refresh token
  fastify.decorate('verifyRefreshToken', async function (token) {
    return fastify.jwt.verify(token, {
      secret: jwtConfig.refreshSecret,
    });
  });
}

export default fp(jwtAuthPlugin, {
  name: 'jwt-auth',
  dependencies: [],
});

User Registration and Login Routes

Now let's build the authentication routes. We'll use bcrypt for password hashing and generate token pairs (access + refresh) on login:

// src/routes/auth.routes.js
import bcrypt from 'bcrypt';

// In-memory user store for demo — use a real database in production
const users = new Map();

export default async function authRoutes(fastify, opts) {
  // Registration endpoint
  fastify.post('/register', {
    schema: {
      body: {
        type: 'object',
        required: ['email', 'password', 'name'],
        properties: {
          email: { type: 'string', format: 'email' },
          password: { type: 'string', minLength: 8 },
          name: { type: 'string', minLength: 2 },
          role: { type: 'string', enum: ['user', 'admin'], default: 'user' },
        },
      },
    },
  }, async (request, reply) => {
    const { email, password, name, role } = request.body;

    // Check if user exists
    if (users.has(email)) {
      return reply.code(409).send({ error: 'Email already registered' });
    }

    // Hash password
    const saltRounds = 12;
    const passwordHash = await bcrypt.hash(password, saltRounds);

    // Store user
    const user = {
      id: crypto.randomUUID(),
      email,
      name,
      role: role || 'user',
      passwordHash,
      createdAt: new Date().toISOString(),
    };
    users.set(email, user);

    // Generate tokens
    const tokenPayload = { sub: user.id, email: user.email, role: user.role };
    const accessToken = fastify.signAccessToken(tokenPayload);
    const refreshToken = fastify.signRefreshToken(tokenPayload);

    reply.code(201).send({
      message: 'User registered successfully',
      user: { id: user.id, email: user.email, name: user.name, role: user.role },
      accessToken,
      refreshToken,
    });
  });

  // Login endpoint
  fastify.post('/login', {
    schema: {
      body: {
        type: 'object',
        required: ['email', 'password'],
        properties: {
          email: { type: 'string', format: 'email' },
          password: { type: 'string' },
        },
      },
    },
  }, async (request, reply) => {
    const { email, password } = request.body;

    const user = users.get(email);
    if (!user) {
      return reply.code(401).send({ error: 'Invalid credentials' });
    }

    const passwordValid = await bcrypt.compare(password, user.passwordHash);
    if (!passwordValid) {
      return reply.code(401).send({ error: 'Invalid credentials' });
    }

    const tokenPayload = { sub: user.id, email: user.email, role: user.role };
    const accessToken = fastify.signAccessToken(tokenPayload);
    const refreshToken = fastify.signRefreshToken(tokenPayload);

    reply.send({
      message: 'Login successful',
      user: { id: user.id, email: user.email, name: user.name, role: user.role },
      accessToken,
      refreshToken,
    });
  });

  // Token refresh endpoint
  fastify.post('/refresh', async (request, reply) => {
    const { refreshToken } = request.body;
    if (!refreshToken) {
      return reply.code(400).send({ error: 'Refresh token required' });
    }

    try {
      const decoded = await fastify.verifyRefreshToken(refreshToken);
      const user = Array.from(users.values()).find(u => u.id === decoded.sub);
      if (!user) {
        return reply.code(401).send({ error: 'User not found' });
      }

      const newPayload = { sub: user.id, email: user.email, role: user.role };
      const newAccessToken = fastify.signAccessToken(newPayload);
      const newRefreshToken = fastify.signRefreshToken(newPayload);

      reply.send({ accessToken: newAccessToken, refreshToken: newRefreshToken });
    } catch (err) {
      reply.code(401).send({ error: 'Invalid refresh token' });
    }
  });

  // Logout (client-side token deletion — JWT is stateless)
  fastify.post('/logout', async (request, reply) => {
    // In production, add the refresh token to a deny list
    reply.send({ message: 'Logged out. Please discard tokens client-side.' });
  });
}

Protected Routes Using JWT

With the plugin and routes in place, you can now protect any route using the authenticate decorator. Here's how to set up protected endpoints with role-based access:

// src/routes/protected.routes.js
export default async function protectedRoutes(fastify, opts) {
  // All routes in this scope require authentication
  fastify.addHook('onRequest', fastify.authenticate);

  // Any authenticated user can access this
  fastify.get('/profile', async (request, reply) => {
    reply.send({
      message: 'Protected profile data',
      user: request.user, // { sub, email, role, iat, exp, iss }
      timestamp: new Date().toISOString(),
    });
  });

  // Admin-only route using the authorize decorator
  fastify.get('/admin/dashboard', {
    preHandler: fastify.authorize('admin'),
  }, async (request, reply) => {
    reply.send({
      message: 'Admin dashboard — sensitive data',
      stats: { totalUsers: users.size, uptime: process.uptime() },
    });
  });

  // Route with multiple roles allowed
  fastify.get('/manage', {
    preHandler: async (request, reply) => {
      const allowedRoles = ['admin', 'manager'];
      if (!allowedRoles.includes(request.user.role)) {
        return reply.code(403).send({ error: 'Insufficient permissions' });
      }
    },
  }, async (request, reply) => {
    reply.send({ message: 'Management area' });
  });
}

Assembling the JWT Application

Here's the complete server file that wires everything together:

// src/server-jwt.js
import Fastify from 'fastify';
import jwtAuthPlugin from './plugins/jwt-auth.plugin.js';
import authRoutes from './routes/auth.routes.js';
import protectedRoutes from './routes/protected.routes.js';

async function buildApp() {
  const fastify = Fastify({
    logger: true,
    trustProxy: true,
  });

  // Register JWT auth plugin
  await fastify.register(jwtAuthPlugin);

  // Register routes
  await fastify.register(authRoutes, { prefix: '/api/auth' });
  await fastify.register(protectedRoutes, { prefix: '/api' });

  // Health check
  fastify.get('/health', async () => ({ status: 'ok', timestamp: Date.now() }));

  return fastify;
}

const app = await buildApp();

try {
  await app.listen({ port: 3000, host: '0.0.0.0' });
  console.log('JWT Auth server running on http://localhost:3000');
} catch (err) {
  app.log.error(err);
  process.exit(1);
}

Testing JWT Endpoints

You can test the flow with curl or any HTTP client:

# Register a user
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"secretpass123","name":"Alice"}'

# Login
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"secretpass123"}'

# Access protected profile (replace TOKEN with accessToken from login response)
curl http://localhost:3000/api/profile \
  -H "Authorization: Bearer TOKEN"

# Refresh token
curl -X POST http://localhost:3000/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"YOUR_REFRESH_TOKEN"}'

Part 2: Session-Based Authentication

When Sessions Beat JWTs

While JWTs excel in stateless scenarios, session-based authentication remains superior for traditional server-rendered applications, multi-page web apps, and situations requiring immediate token invalidation. Sessions store user state on the server, giving you full control over session lifecycle. Fastify's @fastify/session plugin makes session management straightforward and secure.

Session Configuration

Set up a session store. For production, use Redis or a database. For development, an in-memory store works but won't survive restarts:

// src/config/session.config.js
export const sessionConfig = {
  secret: process.env.SESSION_SECRET || 'a-very-long-session-secret-string-32-chars-min',
  cookieName: 'session_id',
  cookie: {
    maxAge: 86400000, // 24 hours
    secure: process.env.NODE_ENV === 'production', // HTTPS only in production
    httpOnly: true,    // Prevent JavaScript access
    sameSite: 'lax',   // CSRF protection
    path: '/',
  },
  saveUninitialized: false,
};

Session Auth Plugin

Create a dedicated session plugin that handles login verification and user deserialization:

// src/plugins/session-auth.plugin.js
import fp from 'fastify-plugin';
import { sessionConfig } from '../config/session.config.js';

async function sessionAuthPlugin(fastify, opts) {
  // Register cookie parser (required for sessions)
  await fastify.register(import('@fastify/cookie'));

  // Register session plugin
  await fastify.register(import('@fastify/session'), {
    ...sessionConfig,
    store: opts.store || new Map(), // Pass a custom store or use in-memory
  });

  // CSRF protection — essential for session-based auth
  await fastify.register(import('@fastify/csrf-protection'), {
    session: true,
    cookieOpts: {
      httpOnly: true,
      sameSite: 'strict',
      path: '/',
      secure: process.env.NODE_ENV === 'production',
    },
  });

  // Decorate with session authentication check
  fastify.decorate('ensureAuthenticated', async function (request, reply) {
    if (!request.session || !request.session.userId) {
      return reply.code(401).send({
        error: 'Unauthorized',
        message: 'Please log in to access this resource',
      });
    }
    // Attach user data to request for downstream handlers
    request.currentUser = request.session.userData;
  });

  // Decorate with login helper
  fastify.decorate('loginUser', async function (request, userData) {
    request.session.userId = userData.id;
    request.session.userData = {
      id: userData.id,
      email: userData.email,
      name: userData.name,
      role: userData.role,
    };
    request.session.lastActivity = Date.now();
    await request.session.save();
  });

  // Decorate with logout helper
  fastify.decorate('logoutUser', async function (request) {
    request.session.destroy();
  });
}

export default fp(sessionAuthPlugin, {
  name: 'session-auth',
  dependencies: [],
});

Session-Based Auth Routes

Unlike JWT routes, session endpoints use cookies for transport and include CSRF protection:

// src/routes/session-auth.routes.js
import bcrypt from 'bcrypt';

const users = new Map(); // Shared store — use DB in production

export default async function sessionAuthRoutes(fastify, opts) {
  // CSRF token endpoint — must be called before form submissions
  fastify.get('/csrf-token', async (request, reply) => {
    const token = await reply.generateCsrf();
    reply.send({ csrfToken: token });
  });

  // Register with session
  fastify.post('/register', {
    schema: {
      body: {
        type: 'object',
        required: ['email', 'password', 'name'],
        properties: {
          email: { type: 'string', format: 'email' },
          password: { type: 'string', minLength: 8 },
          name: { type: 'string', minLength: 2 },
        },
      },
    },
  }, async (request, reply) => {
    const { email, password, name } = request.body;

    if (users.has(email)) {
      return reply.code(409).send({ error: 'Email already registered' });
    }

    const passwordHash = await bcrypt.hash(password, 12);
    const user = {
      id: crypto.randomUUID(),
      email,
      name,
      role: 'user',
      passwordHash,
      createdAt: new Date().toISOString(),
    };
    users.set(email, user);

    // Log user in immediately after registration
    await fastify.loginUser(request, user);

    reply.code(201).send({
      message: 'Registered and logged in',
      user: { id: user.id, email: user.email, name: user.name },
    });
  });

  // Login with session
  fastify.post('/login', async (request, reply) => {
    const { email, password } = request.body;

    const user = users.get(email);
    if (!user) {
      return reply.code(401).send({ error: 'Invalid credentials' });
    }

    const valid = await bcrypt.compare(password, user.passwordHash);
    if (!valid) {
      return reply.code(401).send({ error: 'Invalid credentials' });
    }

    await fastify.loginUser(request, user);
    reply.send({
      message: 'Logged in successfully',
      user: { id: user.id, email: user.email, name: user.name },
    });
  });

  // Logout — destroys server-side session
  fastify.post('/logout', async (request, reply) => {
    await fastify.logoutUser(request);
    reply.send({ message: 'Logged out successfully' });
  });

  // Session status check
  fastify.get('/session', {
    preHandler: fastify.ensureAuthenticated,
  }, async (request, reply) => {
    reply.send({
      session: {
        userId: request.session.userId,
        userData: request.session.userData,
        lastActivity: new Date(request.session.lastActivity).toISOString(),
        maxAge: request.session.cookie.maxAge,
      },
    });
  });
}

Session-Based Protected Routes

// src/routes/session-protected.routes.js
export default async function sessionProtectedRoutes(fastify, opts) {
  fastify.addHook('preHandler', fastify.ensureAuthenticated);

  fastify.get('/dashboard', async (request, reply) => {
    reply.send({
      message: `Welcome back, ${request.currentUser.name}!`,
      user: request.currentUser,
      sessionActive: true,
    });
  });

  // Route that updates session data
  fastify.post('/update-activity', async (request, reply) => {
    request.session.lastActivity = Date.now();
    await request.session.save();
    reply.send({ message: 'Activity timestamp updated' });
  });

  // Admin-only route
  fastify.get('/admin', async (request, reply) => {
    if (request.currentUser.role !== 'admin') {
      return reply.code(403).send({ error: 'Admin access required' });
    }
    reply.send({ message: 'Admin area', users: Array.from(users.values()) });
  });
}

Session Server Assembly

// src/server-session.js
import Fastify from 'fastify';
import sessionAuthPlugin from './plugins/session-auth.plugin.js';
import sessionAuthRoutes from './routes/session-auth.routes.js';
import sessionProtectedRoutes from './routes/session-protected.routes.js';

async function buildSessionApp() {
  const fastify = Fastify({ logger: true, trustProxy: true });

  // Custom session store (in-memory Map for demo)
  const sessionStore = new Map();
  // Implement basic store interface
  const storeAdapter = {
    set: (sid, session, cb) => { sessionStore.set(sid, session); cb(null); },
    get: (sid, cb) => { cb(null, sessionStore.get(sid) || null); },
    destroy: (sid, cb) => { sessionStore.delete(sid); cb(null); },
  };

  await fastify.register(sessionAuthPlugin, { store: storeAdapter });
  await fastify.register(sessionAuthRoutes, { prefix: '/api/auth' });
  await fastify.register(sessionProtectedRoutes, { prefix: '/api' });

  return fastify;
}

const sessionApp = await buildSessionApp();

try {
  await sessionApp.listen({ port: 3001, host: '0.0.0.0' });
  console.log('Session Auth server running on http://localhost:3001');
} catch (err) {
  sessionApp.log.error(err);
  process.exit(1);
}

Testing Session Auth

# Register (session cookie is set automatically)
curl -X POST http://localhost:3001/api/auth/register \
  -H "Content-Type: application/json" \
  -c cookies.txt -b cookies.txt \
  -d '{"email":"bob@example.com","password":"securepass456","name":"Bob"}'

# Access protected dashboard using stored cookies
curl http://localhost:3001/api/dashboard -b cookies.txt

# Logout
curl -X POST http://localhost:3001/api/auth/logout -b cookies.txt

# Verify session is destroyed
curl http://localhost:3001/api/dashboard -b cookies.txt
# Should return 401

Part 3: OAuth Integration with Fastify

OAuth Strategy Overview

OAuth 2.0 enables users to authenticate via third-party providers like Google, GitHub, or Microsoft. Fastify integrates with OAuth through the fastify-oauth2 plugin, which handles the authorization code flow. This approach delegates authentication to trusted providers, reducing your application's security surface while offering users a familiar login experience.

OAuth Plugin Configuration

Set up OAuth providers. We'll configure Google OAuth as an example, but the pattern works identically for GitHub, Facebook, and others:

// src/config/oauth.config.js
export const googleOAuthConfig = {
  name: 'googleOAuth2',
  scope: ['profile', 'email'],
  credentials: {
    client: {
      id: process.env.GOOGLE_CLIENT_ID,
      secret: process.env.GOOGLE_CLIENT_SECRET,
    },
    auth: {
      authorizeHost: 'https://accounts.google.com',
      authorizePath: '/o/oauth2/v2/auth',
      tokenHost: 'https://oauth2.googleapis.com',
      tokenPath: '/token',
    },
  },
  callbackUri: 'http://localhost:3002/api/auth/google/callback',
  verifyUser: async (fastify, credentials, data) => {
    // data contains: access_token, refresh_token, id_token, expires_in
    // Use the access token to fetch user profile from Google
    const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
      headers: { Authorization: `Bearer ${data.access_token}` },
    });
    const profile = await response.json();
    return {
      id: profile.sub,
      email: profile.email,
      name: profile.name,
      picture: profile.picture,
      provider: 'google',
    };
  },
};

OAuth Plugin Implementation

// src/plugins/oauth.plugin.js
import fp from 'fastify-plugin';
import oauth2 from 'fastify-oauth2';
import { googleOAuthConfig } from '../config/oauth.config.js';

async function oauthPlugin(fastify, opts) {
  // Register cookie and session support for OAuth state management
  await fastify.register(import('@fastify/cookie'));
  await fastify.register(import('@fastify/session'), {
    secret: process.env.SESSION_SECRET || 'oauth-session-secret-key',
    cookie: { secure: false, httpOnly: true, sameSite: 'lax' },
    saveUninitialized: true,
  });

  // Register OAuth2 plugin
  await fastify.register(oauth2, {
    name: googleOAuthConfig.name,
    scope: googleOAuthConfig.scope,
    credentials: googleOAuthConfig.credentials,
    callbackUri: googleOAuthConfig.callbackUri,
    startRedirectPath: '/api/auth/google',
    callbackUriPath: '/api/auth/google/callback',
    generateState: true, // CSRF protection via state parameter
    verifyUser: googleOAuthConfig.verifyUser,
  });

  // Decorate with OAuth login helper
  fastify.decorate('oauthLogin', async function (request, user) {
    // Store user in session after OAuth verification
    request.session.userId = user.id;
    request.session.userData = user;
    request.session.authenticatedVia = user.provider;
    await request.session.save();
  });

  // Decorate to check OAuth authentication
  fastify.decorate('ensureOAuth', async function (request, reply) {
    if (!request.session || !request.session.userId) {
      return reply.code(401).send({
        error: 'Unauthorized',
        message: 'Please authenticate via OAuth provider',
      });
    }
    request.currentUser = request.session.userData;
  });
}

export default fp(oauthPlugin, {
  name: 'oauth-auth',
  dependencies: [],
});

OAuth Routes

// src/routes/oauth.routes.js
export default async function oauthRoutes(fastify, opts) {
  // The OAuth plugin automatically creates:
  // GET /api/auth/google — redirects to Google's consent screen
  // GET /api/auth/google/callback — handles the callback from Google

  // Override the callback handler to add custom logic
  fastify.get('/api/auth/google/callback', async (request, reply) => {
    // The plugin processes the callback and calls verifyUser
    // We then handle the verified user
    try {
      // The plugin attaches the verified user to request
      // Access via the plugin's internal mechanism
      const user = request.session?.userData;
      if (!user) {
        return reply.code(401).send({ error: 'OAuth verification failed' });
      }

      await fastify.oauthLogin(request, user);
      reply.send({
        message: 'OAuth login successful',
        user: {
          id: user.id,
          email: user.email,
          name: user.name,
          picture: user.picture,
          provider: user.provider,
        },
      });
    } catch (err) {
      request.log.error(err);
      reply.code(500).send({ error: 'OAuth callback processing failed' });
    }
  });

  // Logout — clears session
  fastify.post('/logout', async (request, reply) => {
    request.session.destroy();
    reply.send({ message: 'Logged out. Session destroyed.' });
  });

  // User info endpoint
  fastify.get('/me', {
    preHandler: fastify.ensureOAuth,
  }, async (request, reply) => {
    reply.send({
      user: request.currentUser,
      authenticatedVia: request.session.authenticatedVia,
    });
  });
}

OAuth Server Assembly

// src/server-oauth.js
import Fastify from 'fastify';
import oauthPlugin from './plugins/oauth.plugin.js';
import oauthRoutes from './routes/oauth.routes.js';

async function buildOAuthApp() {
  const fastify = Fastify({ logger: true, trustProxy: true });

  await fastify.register(oauthPlugin);
  await fastify.register(oauthRoutes);

  // Protected route example
  fastify.get('/api/dashboard', {
    preHandler: fastify.ensureOAuth,
  }, async (request, reply) => {
    reply.send({
      message: `Welcome, ${request.currentUser.name}!`,
      provider: request.currentUser.provider,
      email: request.currentUser.email,
    });
  });

  return fastify;
}

const oauthApp = await buildOAuthApp();

try {
  await oauthApp.listen({ port: 3002, host: '0.0.0.0' });
  console.log('OAuth server running on http://localhost:3002');
  console.log('Visit http://localhost:3002/api/auth/google to authenticate');
} catch (err) {
  oauthApp.log.error(err);
  process.exit(1);
}

Setting Up Google OAuth Credentials

For the OAuth flow to work, you need actual Google Cloud credentials:

# .env additions for OAuth
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret

Steps to obtain these:

Part 4: Combining Authentication Strategies

Real-world applications often need multiple authentication methods. Fastify's plugin encapsulation allows you to compose strategies cleanly. Here's how to combine JWT and session auth in a single application:

// src/plugins/composite-auth.plugin.js
import fp from 'fastify-plugin';

async function compositeAuthPlugin(fastify, opts) {
  // Register both JWT and session plugins in their own scopes
  await fastify.register(import('./jwt-auth.plugin.js'));
  await fastify.register(import('./session-auth.plugin.js'), {
    store: opts.sessionStore,
  });

  // Unified authentication decorator — tries JWT first, falls back to session
  fastify.decorate('unifiedAuth', async function (request, reply) {
    const authHeader = request.headers.authorization;

    if (authHeader && authHeader.startsWith('Bearer ')) {
      // Try JWT authentication
      try {
        await request.jwtVerify();
        request.authMethod = 'jwt';
        return;
      } catch (jwtErr) {
        // JWT failed, fall through to session check
      }
    }

    // Fall back to session authentication
    if (request.session && request.session.userId) {
      request.currentUser = request.session.userData;
      request.authMethod = 'session';
      return;
    }

    return reply.code(401).send({
      error: 'Unauthorized',
      message: 'Please authenticate using JWT (Bearer token) or session cookie',
    });
  });

  // Route that works with either auth method
  fastify.get('/api/unified/profile', {
    preHandler: fastify.unifiedAuth,
  }, async (request, reply) => {
    reply.send({
      message: 'Authenticated via either method',
      authMethod: request.authMethod,
      user: request.user || request.currentUser,
    });
  });
}

export default fp(compositeAuthPlugin, { name: 'composite-auth' });

Best Practices for Fastify Authentication

1. Use Environment Variables for Secrets

Never hardcode secrets, keys, or credentials. Use dotenv or a secrets manager. Rotate JWT signing keys periodically and use separate secrets for access and refresh tokens as demonstrated above.

2. Implement Token Deny Lists for JWT

Since JWTs are stateless, immediate invalidation is impossible without a deny list. For refresh tokens, maintain a Redis set of revoked token IDs. Check this set during token refresh:

// Example deny list check (conceptual)
const isRevoked = await redis.sismember('revoked_tokens', decoded.jti);
if (isRevoked) {
  return reply.code(401).send({ error: 'Token has been revoked' });
}

3. Set Appropriate Cookie Flags for Sessions

Always use httpOnly: true, secure: true in production (HTTPS), and sameSite: 'lax' or 'strict'. These flags prevent XSS attacks from stealing session cookies and mitigate CS

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles