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-clientQuick Start
OIDC Discovery (recommended)
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 theX-Config-Idheader on token exchange so the OAuth server can look up the tenant.clientId— the value the tenant has configured (in the Admin Console) as theaudclaim on issued id_tokens. The verifier checks the token'saudagainst 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