← Back to DevBytes

Hapi Authentication: JWT, Sessions, and OAuth Integration

Introduction to Hapi Authentication

Authentication is the gatekeeper of your application. In Hapi, a Node.js framework renowned for its configuration-driven approach, authentication is not an afterthought—it’s a first-class concept built into the framework’s request lifecycle. Hapi provides a clean, plugin-based architecture that allows you to integrate multiple authentication strategies seamlessly: JSON Web Tokens (JWT) for stateless API auth, cookie-based sessions for traditional browser apps, and OAuth for social login or federated identity.

Why does this matter? Modern applications rarely rely on a single authentication mechanism. A REST API might use JWT, while the admin dashboard relies on session cookies, and the “Sign in with Google” button leverages OAuth. Hapi’s authentication scheme model lets you define each strategy once and apply it declaratively to any route. This tutorial covers everything from setting up your first JWT strategy to combining it with sessions and OAuth providers, complete with practical code examples you can run today.

Authentication Strategies in Hapi

🚀 Deploy your AI agent in 10 minutes

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

Try it free →

Hapi authentication revolves around schemes and strategies. A scheme is a generic authentication method (like Basic, JWT, or Cookie), while a strategy is a configured instance of a scheme with specific options and a validation function. You register schemes via plugins, create strategies in server configuration, and then assign them to routes using the auth property. Hapi handles the rest—challenging unauthorized requests, parsing credentials, and injecting the authenticated user into the request object.

We’ll explore three of the most common authentication patterns in Hapi:

Each section provides a complete, runnable example and highlights production-ready best practices.

1. JWT Authentication

JWT (JSON Web Token) authentication allows a client to prove its identity by presenting a digitally signed token, typically in the Authorization header. Hapi integrates JWT effortlessly via the hapi-auth-jwt2 plugin. The server validates the token’s signature and expiration, extracts the payload, and calls your custom validation function to decide if the credentials are still valid (e.g., user exists in the database, not revoked). This stateless nature removes the need for server-side session storage, making it perfect for scalable APIs.

Setting Up hapi-auth-jwt2

First, install the plugin:

npm install hapi hapi-auth-jwt2 jsonwebtoken

Then register the plugin and define a JWT strategy. The strategy requires a validate function that receives the decoded token payload and must return an object with { isValid, credentials }.

const Hapi = require('@hapi/hapi');
const jwt = require('jsonwebtoken');
const Jwt = require('hapi-auth-jwt2');

const start = async () => {
  const server = Hapi.server({ port: 3000 });

  // Secret key – store in environment variable in production!
  const JWT_SECRET = 'never-expose-this-in-code';

  await server.register(Jwt);

  server.auth.strategy('jwt', 'jwt', {
    key: JWT_SECRET,
    validate: async (decoded, request, h) => {
      // Perform additional checks: user still exists? token revoked?
      // For this example, we trust the payload if token is valid.
      if (!decoded.id) {
        return { isValid: false };
      }
      // Attach user info to credentials for later use in route handlers
      return { isValid: true, credentials: { userId: decoded.id, scope: decoded.scope } };
    },
    verifyOptions: {
      algorithms: ['HS256'],           // only allow HS256
      ignoreExpiration: false,        // always check exp
    },
  });

  // Default strategy (optional) – applies to routes that don't specify a strategy
  server.auth.default('jwt');

  // Login route – generates a JWT
  server.route({
    method: 'POST',
    path: '/login',
    options: { auth: false },   // public route
    handler: async (request, h) => {
      const { username, password } = request.payload;
      // In real apps, verify credentials against DB
      if (username !== 'admin' || password !== 'secret') {
        return h.response({ error: 'Invalid credentials' }).code(401);
      }
      const token = jwt.sign(
        { id: 42, username: 'admin', scope: ['user', 'admin'] },
        JWT_SECRET,
        { algorithm: 'HS256', expiresIn: '1h' }
      );
      return { token };
    },
  });

  // Protected route – requires valid JWT
  server.route({
    method: 'GET',
    path: '/me',
    options: { auth: 'jwt' },
    handler: (request, h) => {
      return { userId: request.auth.credentials.userId, scope: request.auth.credentials.scope };
    },
  });

  await server.start();
  console.log('Server running on %s', server.info.uri);
};

start().catch(console.error);

In this example, the /login route is public (auth: false) and returns a JWT. The /me route requires the jwt strategy. If a request arrives without a valid Authorization: Bearer <token> header, Hapi automatically responds with a 401 Unauthorized error.

JWT Best Practices

2. Session / Cookie Authentication

For traditional web applications where a browser maintains a session, cookie-based authentication shines. The server creates a session identifier, stores it in a secure cookie, and keeps session data server-side (in memory, Redis, or a database). Hapi integrates session cookies via the @hapi/cookie plugin (formerly hapi-auth-cookie). It encrypts the cookie content, provides built-in mechanisms for login/logout, and supports fine-grained cookie flags.

Setting Up hapi-auth-cookie (Session)

Install the required packages:

npm install @hapi/hapi @hapi/cookie @hapi/iron

The cookie plugin uses @hapi/iron for encryption. Define a strategy with a cookie scheme, a password (encryption key), and a validation function that receives the decrypted cookie content.

const Hapi = require('@hapi/hapi');
const Cookie = require('@hapi/cookie');

const start = async () => {
  const server = Hapi.server({ port: 3000 });

  // Encryption keys – at least 32 chars, use env variables in production
  const COOKIE_PASSWORD = 'a-very-long-and-secure-password-at-least-32-chars';

  await server.register(Cookie);

  server.auth.strategy('session', 'cookie', {
    cookie: {
      name: 'sid',             // cookie name
      password: COOKIE_PASSWORD,
      isSecure: false,         // set to true in production (HTTPS)
      isHttpOnly: true,
      path: '/',
      sameSite: 'Strict',      // CSRF protection
      // ttl: 24 * 60 * 60 * 1000  // 1 day in ms (optional)
    },
    validateFunc: async (request, session) => {
      // session is the decrypted cookie content
      // Verify user still exists or session is not expired in your store
      if (!session || !session.userId) {
        return { valid: false };
      }
      // Optionally re-hydrate user object from DB
      return { valid: true, credentials: { userId: session.userId, role: session.role } };
    },
  });

  server.auth.default('session');

  // Login route – validates credentials and sets the cookie
  server.route({
    method: 'POST',
    path: '/login',
    options: { auth: { mode: 'try' } },   // allow access even if not authenticated
    handler: async (request, h) => {
      const { username, password } = request.payload;
      if (username !== 'alice' || password !== 'wonderland') {
        return h.response({ error: 'Invalid credentials' }).code(401);
      }
      // Create session object – store minimal info
      const session = { userId: 7, username: 'alice', role: 'user' };
      request.cookieAuth.set(session);
      return { message: 'Logged in' };
    },
  });

  // Protected route
  server.route({
    method: 'GET',
    path: '/dashboard',
    handler: (request, h) => {
      return { userId: request.auth.credentials.userId, role: request.auth.credentials.role };
    },
  });

  // Logout route
  server.route({
    method: 'GET',
    path: '/logout',
    handler: (request, h) => {
      request.cookieAuth.clear();
      return { message: 'Logged out' };
    },
  });

  await server.start();
  console.log('Server running on %s', server.info.uri);
};

start().catch(console.error);

Here request.cookieAuth.set(session) encrypts the session object, stores it in the cookie, and sends it to the browser. Subsequent requests automatically decrypt and validate the cookie via the validateFunc. Logout clears the cookie.

Session Best Practices

3. OAuth Integration

OAuth allows users to authenticate via third-party providers like Google, GitHub, Facebook, or Auth0. Hapi’s @hapi/bell plugin handles the OAuth dance: it initiates the authorization request, exchanges codes for tokens, retrieves the user profile, and calls your validation function. You can then link this OAuth identity to a local JWT or session, giving users a seamless login experience.

Setting Up bell for OAuth

Install bell and any required dependencies:

npm install @hapi/hapi @hapi/bell @hapi/cookie @hapi/jwt jsonwebtoken

Configure an OAuth strategy with a provider (e.g., Google), your app’s client ID and secret, and a validate function. After successful authentication, you typically issue your own token or session.

const Hapi = require('@hapi/hapi');
const Bell = require('@hapi/bell');
const Cookie = require('@hapi/cookie');
const jwt = require('jsonwebtoken');

const start = async () => {
  const server = Hapi.server({ port: 3000 });

  // In production, load these from environment variables
  const GOOGLE_CLIENT_ID = 'your-google-client-id';
  const GOOGLE_CLIENT_SECRET = 'your-google-client-secret';
  const JWT_SECRET = 'jwt-secret-for-after-oauth';
  const COOKIE_PASSWORD = 'a-very-long-cookie-password-at-least-32-chars';

  await server.register([Bell, Cookie]);

  // OAuth strategy using Bell (Google)
  server.auth.strategy('google', 'bell', {
    provider: 'google',
    password: COOKIE_PASSWORD,          // used to encrypt the Bell temporary cookie (state)
    clientId: GOOGLE_CLIENT_ID,
    clientSecret: GOOGLE_CLIENT_SECRET,
    isSecure: false,                    // set true in production (HTTPS)
    location: true,                     // include location data
    scope: ['openid', 'profile', 'email'],
    // Bell stores the state in a cookie encrypted with `password`
    // The validate function receives the credentials from the provider
    validate: async (credentials, request, h) => {
      // credentials.profile contains the user's Google profile
      const profile = credentials.profile;
      // Find or create user in your database using profile.email / profile.id
      // For this example, we just use the profile directly
      if (!profile || !profile.email) {
        return { isValid: false };
      }
      // Return the user object that will be attached to request.auth.credentials
      return { isValid: true, credentials: { provider: 'google', email: profile.email, displayName: profile.displayName } };
    },
  });

  // Session strategy to maintain the logged-in state after OAuth
  server.auth.strategy('session', 'cookie', {
    cookie: {
      name: 'sid',
      password: COOKIE_PASSWORD,
      isSecure: false,
      isHttpOnly: true,
      path: '/',
      sameSite: 'Strict',
    },
    validateFunc: async (request, session) => {
      if (!session || !session.userId) return { valid: false };
      return { valid: true, credentials: session };
    },
  });

  // Route: start OAuth login (redirects to Google)
  server.route({
    method: 'GET',
    path: '/auth/google',
    options: { auth: 'google' },   // triggers Bell's OAuth flow
    handler: (request, h) => {
      // This handler is never reached; Bell redirects to Google and then to /auth/google/callback
      return h.redirect('/');
    },
  });

  // OAuth callback – Bell automatically intercepts and processes the code exchange
  server.route({
    method: 'GET',
    path: '/auth/google/callback',
    options: { auth: 'google' },
    handler: async (request, h) => {
      // At this point, request.auth.credentials contains the validated Google profile
      const googleProfile = request.auth.credentials;

      // After OAuth success, we can generate a JWT or set a session cookie
      // Option A: issue a JWT and redirect with token in query (not ideal, use session instead)
      // Option B: set a session cookie and redirect to dashboard

      // Using session cookie (recommended for browser apps)
      request.cookieAuth.set({
        userId: googleProfile.email,   // or a DB user id
        email: googleProfile.email,
        displayName: googleProfile.displayName,
        provider: 'google',
      });

      return h.redirect('/dashboard');
    },
  });

  // Dashboard – requires session
  server.route({
    method: 'GET',
    path: '/dashboard',
    options: { auth: 'session' },
    handler: (request, h) => {
      return { user: request.auth.credentials };
    },
  });

  // If you prefer JWT after OAuth (for API clients), you can do:
  server.route({
    method: 'GET',
    path: '/auth/google/jwt-callback',
    options: { auth: 'google' },
    handler: async (request, h) => {
      const profile = request.auth.credentials;
      const token = jwt.sign(
        { email: profile.email, displayName: profile.displayName },
        JWT_SECRET,
        { expiresIn: '1h' }
      );
      return { token };
    },
  });

  await server.start();
  console.log('Server running on %s', server.info.uri);
};

start().catch(console.error);

Bell automatically creates the required redirect endpoints (/auth/google and the callback) and handles the OAuth 2.0 authorization code flow. The validate function gives you the user profile from Google; you can then find or create a user in your database and return an { isValid, credentials } object. In the callback handler, we set a session cookie (or issue a JWT) to establish the user’s authenticated state for subsequent requests.

OAuth Best Practices

Combining Multiple Authentication Strategies

A powerful Hapi feature is the ability to use multiple strategies simultaneously. You can assign different strategies to different routes, or even require more than one strategy on a single route using the auth.strategies array (with a mode like 'required' or 'optional').

For example, an API might expose public endpoints that accept either a JWT (for programmatic access) or a session cookie (for browser-based clients). Here’s how to configure such a route:

server.route({
  method: 'GET',
  path: '/api/data',
  options: {
    auth: {
      strategies: ['jwt', 'session'],
      mode: 'required',       // at least one must succeed
    },
  },
  handler: (request, h) => {
    // request.auth.credentials will come from whichever strategy succeeded
    return { data: 'sensitive', user: request.auth.credentials };
  },
});

You can also combine strategies for different route groups. Use server.auth.default() with a strategy for the majority of routes, and override specific routes with a different strategy name. This declarative approach keeps your authentication logic centralized and easy to audit.

Best Practices for Hapi Authentication (Summary)

Conclusion

Hapi’s authentication layer gives you the flexibility to implement JWT, sessions, and OAuth with minimal boilerplate while maintaining strong security defaults. By defining strategies once and applying them declaratively to routes, you keep your code clean, your security posture consistent, and your application ready to handle diverse client environments. Whether you’re building a REST API, a traditional web app, or a hybrid system with social login, the patterns shown here provide a solid foundation. Remember to follow the best practices—secure your secrets, validate tokens thoroughly, and always use HTTPS in production—to build authentication that’s both robust and user-friendly.

🚀 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