Node.js Backend SDK Quickstart

Integrate Hawcx OAuth authentication in your Node.js backend

Hawcx OAuth Client SDK for Node.js

Add passwordless authentication to your Node.js backend. Exchange authorization codes for verified user claims and manage MFA.

Installation

npm install @hawcx/oauth-client

Quick Start

HawcxOAuth.fromIssuer(...) discovers the OAuth endpoints once at startup from <issuer>/.well-known/openid-configuration and wires a verifier that enforces signature + iss + aud + exp + nbf on every subsequent verifyToken call. Construct once at app boot and reuse — don't run the discovery fetch per request.

import { HawcxOAuth } from '@hawcx/oauth-client';

// Construct once at startup. Throws DiscoveryError if the issuer is
// unreachable or the discovery document is malformed.
export const oauth = await HawcxOAuth.fromIssuer({
  issuer: process.env.HAWCX_BASE_URL!,    // discovery + expected `iss`
  configId: process.env.HAWCX_CONFIG_ID!, // → X-Config-Id header
  clientId: process.env.HAWCX_CLIENT_ID!, // → expected `aud` on id_tokens
});

app.post('/exchange', async (req, res) => {
  try {
    const { authCode, codeVerifier } = req.body;

    // Exchange the authorization code for verified claims.
    // Verification enforces signature + iss + aud + exp + nbf.
    const { claims } = await oauth.exchangeCode(authCode, codeVerifier);

    // Mint your own access token / session
    const sessionToken = jwt.sign(
      { sub: claims.sub, email: claims.email },
      process.env.APP_SESSION_SECRET!,
      { expiresIn: '1h' }
    );

    res.json({ sessionToken, userId: claims.sub });
  } catch (error) {
    console.error('OAuth exchange failed:', error);
    res.status(401).json({ error: 'Authentication failed' });
  }
});

configId and clientId are different values

Hawcx separates the tenant routing key from the audience claim:

  • configId — opaque tenant routing key, sent as the X-Config-Id header on token exchange so the OAuth server can look up the tenant.
  • clientId — the value the tenant has configured (in the Admin Console) as the aud claim on issued id_tokens. The verifier checks the token's aud against this exact string.

Passing the same value for both fails verification with Unexpected JWT audience unless the tenant has configured them identically.

If your authorization request registered a redirect_uri (RFC 6749 §4.1.3 requires it on the token request too), pass it as the third arg:

const { claims } = await oauth.exchangeCode(
  authCode,
  codeVerifier,
  'https://app.example.com/auth/callback',
);

If your flow binds a nonce to the authorization request, pass it to verifyToken so it's checked:

const claims = await oauth.verifyToken(idToken, { nonce: expectedNonce });

Need to inspect what discovery resolved to (for debugging "did we resolve the right token endpoint?"):

oauth.discoveryMetadata;
// { issuer, token_endpoint, jwks_uri, id_token_signing_alg_values_supported, ... }

Legacy mode (no iss / aud enforcement)

The legacy single-arg constructor is still supported for backward compatibility with 4.x callers. It hits the fixed endpoints <baseUrl>/oauth2/token and <baseUrl>/keys directly. Verification covers signature + exp + nbf, but not iss or aud — the legacy constructor has no way to know what to expect. The optional { nonce } check on verifyToken still applies. Prefer the discovery path above for new code.

import { HawcxOAuth } from '@hawcx/oauth-client';

const oauth = new HawcxOAuth({
  configId: process.env.HAWCX_CONFIG_ID!,
  baseUrl: process.env.HAWCX_BASE_URL!,
});

const { claims } = await oauth.exchangeCode(authCode, codeVerifier);

Delegation Client (MFA Setup)

For advanced use cases like setting up MFA for users programmatically:

import { DelegationClient, MfaMethod } from '@hawcx/oauth-client';

// Initialize the delegation client with your secret key blob
const client = DelegationClient.fromSecretKey({
  secretKey: process.env.HAWCX_SECRET_KEY!,
  baseUrl: process.env.HAWCX_BASE_URL!,
  apiKey: process.env.HAWCX_CONFIG_ID
});

// Initiate MFA setup (Email, SMS, or TOTP)
const result = await client.mfa.initiate({
  userId: '[email protected]',
  mfaMethod: MfaMethod.SMS,
  phoneNumber: '+15551234567'
});

// Verify OTP and complete MFA setup
await client.mfa.verify({
  userId: '[email protected]',
  sessionId: result.session_id,
  otp: '123456'
});

// Get user credentials
const creds = await client.users.getCredentials('[email protected]');
console.log(`MFA method: ${creds.mfa_method}`);

Configuration

Environment Variables

For OAuth Code Exchange:

# Base URL — from Admin Console → Project Settings (environment-specific)
HAWCX_BASE_URL="<Base URL from Admin Console>"
# Config ID — from Admin Console → Project Settings (tenant routing key)
HAWCX_CONFIG_ID="<Config ID from Admin Console>"
# Client ID — from Admin Console → Project Settings (expected `aud` on id_tokens)
HAWCX_CLIENT_ID="<Client ID from Admin Console>"

Where to find these values

Open the Hawcx Admin Console, go to Project Settings, and copy the Base URL, Config ID, and Client ID. All three are environment-specific. HAWCX_CLIENT_ID is only required by the discovery-mode API (fromIssuer); the legacy single-arg constructor doesn't use it.

For Delegation (MFA setup / management):

# Credential blob from the Hawcx dashboard
HAWCX_SECRET_KEY="hwx_sk_v1_..."

Error Handling

import {
  HawcxOAuth,
  DiscoveryError,
  TokenExchangeError,
  TokenVerificationError,
} from '@hawcx/oauth-client';

try {
  const oauth = await HawcxOAuth.fromIssuer({
    issuer: process.env.HAWCX_BASE_URL!,
    configId: process.env.HAWCX_CONFIG_ID!,
    clientId: process.env.HAWCX_CLIENT_ID!,
  });
  const { claims } = await oauth.exchangeCode(authCode, codeVerifier);
} catch (error) {
  if (error instanceof DiscoveryError) {
    // Issuer unreachable, non-2xx, malformed JSON, or missing required fields.
    // `error.cause` carries the underlying network / parse error if any.
    console.error('Discovery failed:', error.message, error.cause);
  } else if (error instanceof TokenExchangeError) {
    console.error('Exchange failed:', error.message, error.statusCode);
  } else if (error instanceof TokenVerificationError) {
    // "Token expired", "Invalid signature", "Unexpected JWT audience: ...", etc.
    console.error('Verification failed:', error.message);
  } else {
    throw error;
  }
}

Integration Examples

All examples below use the recommended discovery API. They construct HawcxOAuth once at startup so the discovery fetch doesn't run per request — that fetch is bounded at 5s connect + 5s read so an unreachable issuer fails the bootstrap rather than hanging it.

Express.js

import express, { Request, Response } from 'express';
import { HawcxOAuth } from '@hawcx/oauth-client';

const app = express();
app.use(express.json());

// Top-level await is fine in ESM. For CommonJS, wrap in an async bootstrap fn.
const oauth = await HawcxOAuth.fromIssuer({
  issuer: process.env.HAWCX_BASE_URL!,
  configId: process.env.HAWCX_CONFIG_ID!,
  clientId: process.env.HAWCX_CLIENT_ID!,
});

app.post('/exchange', async (req: Request, res: Response) => {
  const { authCode, codeVerifier } = req.body;

  if (!authCode || !codeVerifier) {
    return res.status(400).json({ error: 'Missing authCode or codeVerifier' });
  }

  try {
    const { claims } = await oauth.exchangeCode(authCode, codeVerifier);
    req.session.userId = claims.sub;
    req.session.email = claims.email;
    res.json({ success: true, userId: claims.sub });
  } catch (error) {
    console.error('Authentication failed:', error);
    res.status(401).json({ error: 'Authentication failed' });
  }
});

app.listen(3000, () => console.log('Server running on port 3000'));

Fastify

import Fastify, { FastifyRequest, FastifyReply } from 'fastify';
import { HawcxOAuth } from '@hawcx/oauth-client';

const app = Fastify();
const oauth = await HawcxOAuth.fromIssuer({
  issuer: process.env.HAWCX_BASE_URL!,
  configId: process.env.HAWCX_CONFIG_ID!,
  clientId: process.env.HAWCX_CLIENT_ID!,
});

app.post<{ Body: { authCode: string; codeVerifier: string } }>(
  '/exchange',
  async (request: FastifyRequest, reply: FastifyReply) => {
    const { authCode, codeVerifier } = request.body;

    try {
      const { claims } = await oauth.exchangeCode(authCode, codeVerifier);
      reply.send({ success: true, userId: claims.sub });
    } catch (error) {
      reply.status(401).send({ error: 'Authentication failed' });
    }
  }
);

app.listen({ port: 3000 }, (err) => {
  if (err) throw err;
  console.log('Server running on port 3000');
});

Next.js (App Router, Route Handler)

// app/api/auth/exchange/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { HawcxOAuth } from '@hawcx/oauth-client';

// Cache the SDK promise at module scope so the discovery fetch happens once
// per server lambda, not per request.
const oauthPromise = HawcxOAuth.fromIssuer({
  issuer: process.env.HAWCX_BASE_URL!,
  configId: process.env.HAWCX_CONFIG_ID!,
  clientId: process.env.HAWCX_CLIENT_ID!,
});

export async function POST(req: NextRequest) {
  const { authCode, codeVerifier } = await req.json();

  try {
    const oauth = await oauthPromise;
    const { claims } = await oauth.exchangeCode(authCode, codeVerifier);
    return NextResponse.json({ success: true, userId: claims.sub });
  } catch (error) {
    return NextResponse.json({ error: 'Authentication failed' }, { status: 401 });
  }
}

PKCE and redirect_uri

codeVerifier is required for the exchange. Store it on the client and send it alongside authCode when your backend calls exchangeCode().

If your original authorization request included a redirect_uri, RFC 6749 §4.1.3 requires the token request to carry the same value. Pass it as the third argument:

const { claims } = await oauth.exchangeCode(
  authCode,
  codeVerifier,
  'https://app.example.com/auth/callback',
);

Confidential clients (private_key_jwt)

Optional — most apps stay on PKCE. For a backend that should authenticate with a key it holds, switch the project to Enhanced mode in the Admin Console, then attach the Ed25519 private JWK once with withClientAssertion (discovery mode):

import { HawcxOAuth, ClientAssertionSigner } from '@hawcx/oauth-client';

const oauth = (await HawcxOAuth.fromIssuer({
  issuer: process.env.HAWCX_BASE_URL!,
  configId: process.env.HAWCX_CONFIG_ID!,
  clientId: process.env.HAWCX_CLIENT_ID!,
})).withClientAssertion(
  ClientAssertionSigner.ed25519FromJwk(process.env.HAWCX_OAUTH_PRIVATE_KEY_JWK!),
);

// exchangeCode now sends the signed client_assertion alongside PKCE
const { claims } = await oauth.exchangeCode(authCode, codeVerifier);

See Confidential Clients (private_key_jwt) for the Admin Console setup and a no-SDK (stock JWT library) example.

Next Steps

  • View the complete API reference for advanced features and detailed method signatures
  • Set up MFA management for your users
  • Join our developer community on Slack for support and updates