Skip to content

Bulletproof Authentication

ZinTrust ships with JwtAuthMiddleware for standard Bearer JWT auth. For applications that need strong protection against stolen JWTs, ZinTrust provides Bulletproof Auth — a layered middleware that combines JWT verification with a proof-of-possession signed request, strict device binding, and replay protection.

TL;DR: If an attacker steals a JWT, they are still blocked — they cannot generate the required signed-request proof without the per-device secret stored on the user's device.


How It Works — The 7–9 Layers

Each layer must pass in order. Any failure returns 401 Unauthorized immediately.

#LayerWhat it checks
1Authorization headerAuthorization: Bearer <jwt> is present
2Token revocationJWT has not been revoked via TokenRevocation
3Signed-request headersAll five x-zt-* headers are present
4Timestamp freshnessx-zt-timestamp is within the replay window (default ±60 s)
5Nonce replay guard(keyId, nonce) has not been seen before
6Signature verificationHMAC-SHA256 of the canonical request matches x-zt-signature
7Device bindingx-zt-device-id === x-zt-key-id; optionally matches deviceId JWT claim
8Timezone binding (optional)x-zt-timezone matches the tz JWT claim when present
9User-Agent binding (optional)SHA-256(User-Agent) matches the uaHash JWT claim when present

Layers 8 and 9 are weaker by nature (headers can be spoofed), but they add meaningful friction and can surface anomalous access patterns.


Login Flow

The Bulletproof flow requires the server to issue a device secret alongside the JWT at login time. The client stores both and uses the secret to sign every subsequent request.

Client                                    Server
  │                                          │
  │  POST /auth/login  {email, password}     │
  │ ────────────────────────────────────>    │
  │                                          │  Verify credentials
  │                                          │  • Sign JWT with deviceId claim
  │                                          │  • Generate per-device secret
  │  200 { jwt, deviceId, deviceSecret }     │
  │ <────────────────────────────────────    │
  │                                          │
  │  Store jwt + deviceId + deviceSecret     │
  │  in secure storage (Keychain / KV)       │
  │                                          │
  │  GET /api/me  (signed request)           │
  │  Authorization: Bearer <jwt>             │
  │  x-zt-key-id: <deviceId>                │
  │  x-zt-timestamp: <nowMs>                 │
  │  x-zt-nonce: <uuid>                      │
  │  x-zt-body-sha256: <sha256>              │
  │  x-zt-signature: <hmac>                  │
  │  x-zt-device-id: <deviceId>             │
  │ ────────────────────────────────────>    │
  │                                          │  BulletproofAuth checks layers 1–9
  │  200 { ... }                             │
  │ <────────────────────────────────────    │

Backend Setup

1. Register the middleware

Add the bulletproof key to your middleware config (config/middleware.ts):

ts
import { BulletproofAuthMiddleware } from '@middleware/BulletproofAuthMiddleware';

export default {
  // ... your other middleware

  bulletproof: BulletproofAuthMiddleware.create({
    /**
     * Recommended: resolve a per-device secret by keyId.
     * keyId === deviceId — look it up from your DB, cache, or KV store.
     */
    getSecretForKeyId: async (keyId) => {
      const device = await DeviceRepository.findByDeviceId(keyId);
      return device?.signingSecret ?? undefined;
    },
  }),
};

2. Protect your routes

ts
// routes/api.ts
Router.get('/me', 'UserController.me', {
  middleware: ['auth', 'bulletproof'],
});

Router.get('/profile', 'ProfileController.show', {
  middleware: ['auth', 'bulletproof'],
});

3. Login controller — full example

This is where you issue a JWT with a deviceId claim and generate a per-device signing secret.

ts
// app/Controllers/AuthController.ts
import { Controller } from 'zintrust';
import { Request, Response } from '@types/http';
import { randomBytes } from 'node:crypto';

export class AuthController extends Controller {
  /**
   * POST /auth/login
   * Body: { email: string; password: string; deviceId?: string }
   *
   * Returns: { jwt, deviceId, deviceSecret }
   */
  async login(req: Request, res: Response) {
    const { email, password } = req.body as { email: string; password: string };

    // 1. Verify credentials
    const user = await UserRepository.findByEmail(email);
    if (!user || !(await user.verifyPassword(password))) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }

    // 2. Assign a stable deviceId (or accept one from the client)
    const deviceId: string =
      (req.body as { deviceId?: string }).deviceId ??
      `dev_${randomBytes(16).toString('hex')}`;

    // 3. Generate a strong per-device signing secret
    const deviceSecret = `base64:${randomBytes(32).toString('base64')}`;

    // 4. Persist the device record
    await DeviceRepository.upsert({
      userId: user.id,
      deviceId,
      signingSecret: deviceSecret,
      userAgent: req.headers['user-agent'] ?? '',
      lastSeenAt: new Date(),
    });

    // 5. Mint JWT — include deviceId so the middleware can validate binding
    const jwt = await Jwt.sign({
      sub: String(user.id),
      email: user.email,
      role: user.role,
      deviceId,
      // Optional extras for stronger binding (layers 8 + 9)
      tz: req.headers['x-zt-timezone'] as string | undefined,
      uaHash: req.headers['x-zt-user-agent-hash'] as string | undefined,
    });

    return res.json({ jwt, deviceId, deviceSecret });
  }

  /**
   * POST /auth/logout
   * Revokes the JWT and removes the device record.
   */
  async logout(req: Request, res: Response) {
    const token = (req.headers.authorization ?? '').replace('Bearer ', '');
    await TokenRevocation.revoke(token);

    const deviceId = req.header('x-zt-device-id');
    if (deviceId) {
      await DeviceRepository.removeByDeviceId(deviceId);
    }

    return res.json({ message: 'Logged out' });
  }
}

Register the routes:

ts
Router.post('/auth/login', 'AuthController.login');
Router.post('/auth/logout', 'AuthController.logout', {
  middleware: ['auth', 'bulletproof'],
});

4. Quick start — single shared secret

For simple setups (e.g. server-to-server integrations), skip per-device secrets and use a single environment secret:

bash
# .env
BULLETPROOF_SIGNING_SECRET=base64:your-secret-here
BULLETPROOF_SIGNING_SECRET_BK=[]       # rotation backups (JSON array)

Generate the secret via the CLI:

bash
zin key:bulletproof

Fallback chain (when BULLETPROOF_SIGNING_SECRET is empty): BULLETPROOF_SIGNING_SECRETAUTH_KEYAPP_KEY

In this mode, all clients share the same secret. This is acceptable for trusted server-to-server calls, but per-device secrets are strongly preferred for user-facing apps.

5. Key rotation

Rotate BULLETPROOF_SIGNING_SECRET without breaking in-flight clients:

bash
# Generates a new secret and moves the current one to BULLETPROOF_SIGNING_SECRET_BK
zin key:bulletproof

# Limit rotation history to 3 entries
zin key:bulletproof --max-backups 3

The middleware automatically verifies against all backup secrets during the overlap window.

bash
# .env (after rotation)
BULLETPROOF_SIGNING_SECRET=base64:<new-key>
BULLETPROOF_SIGNING_SECRET_BK=["base64:<old-key>", "base64:<older-key>"]

Frontend Usage

Install the signer package

bash
npm i @zintrust/signer

@zintrust/signer is a zero-dependency WebCrypto package that works in browsers, Node 20+, and Cloudflare Workers.

Login

ts
// lib/auth.ts
import { SignedRequest } from '@zintrust/signer';

interface LoginResult {
  jwt: string;
  deviceId: string;
  deviceSecret: string;
}

export async function login(email: string, password: string): Promise<LoginResult> {
  const res = await fetch('/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });

  if (!res.ok) {
    throw new Error('Login failed');
  }

  const data = (await res.json()) as LoginResult;

  // Store securely — for high-security apps use the platform keychain
  sessionStorage.setItem('jwt', data.jwt);
  sessionStorage.setItem('deviceId', data.deviceId);
  sessionStorage.setItem('deviceSecret', data.deviceSecret);

  return data;
}

Making an authenticated request

ts
// lib/api.ts
import { SignedRequest } from '@zintrust/signer';

export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
  const jwt = sessionStorage.getItem('jwt')!;
  const deviceId = sessionStorage.getItem('deviceId')!;
  const deviceSecret = sessionStorage.getItem('deviceSecret')!;

  const method = (init.method ?? 'GET').toUpperCase();
  const url = new URL(path, window.location.origin);
  const body = typeof init.body === 'string' ? init.body : '';

  // Generate the five signed-request headers
  const signed = await SignedRequest.createHeaders({
    method,
    url,
    body,
    keyId: deviceId,
    secret: deviceSecret,
  });

  const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;

  return fetch(url, {
    ...init,
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${jwt}`,
      'x-zt-device-id': deviceId,
      'x-zt-timezone': timezone,
      'User-Agent': navigator.userAgent,
      ...(init.headers as Record<string, string> | undefined),
      ...signed, // x-zt-key-id, x-zt-timestamp, x-zt-nonce, x-zt-body-sha256, x-zt-signature
    },
  });
}

Usage:

ts
const res = await apiFetch('/api/me');
const profile = await res.json();

React hook example

ts
// hooks/useApi.ts
import { useCallback } from 'react';
import { apiFetch } from '@/lib/api';

export function useApi() {
  const get = useCallback(
    (path: string) => apiFetch(path, { method: 'GET' }),
    [],
  );

  const post = useCallback(
    (path: string, body: unknown) =>
      apiFetch(path, {
        method: 'POST',
        body: JSON.stringify(body),
      }),
    [],
  );

  return { get, post };
}

Route Handler Access (req.user)

Both JwtAuthMiddleware and Bulletproof Auth attach the verified JWT payload to the request:

ts
Router.get('/me', async (req, res) => {
  // req.user is fully typed and verified
  const { sub, email, role, deviceId } = req.user!;
  return res.json({ id: sub, email, role, deviceId });
});

RequestContext.setUserId(req, payload.sub) is called automatically when sub is present.


CLI Tools

zin key:bulletproof

Generates a new BULLETPROOF_SIGNING_SECRET and writes it to .env. The current secret is automatically moved to the rotation backup array.

bash
# Generate and save to .env
zin key:bulletproof

# Print only (do not write to .env)
zin key:bulletproof --show

# Keep at most 3 old secrets in the backup array (default: 5)
zin key:bulletproof --max-backups 3

Aliases: zin bulletproof:key, zin key:signer

zin jwt:dev

Mints a development JWT with Bulletproof-compatible claims for manual testing.

bash
# Basic token with deviceId binding
zin jwt:dev --sub 1 --email dev@example.com --role admin --device-id dev_abc123

# Include timezone and User-Agent hash (covers layers 8 + 9)
zin jwt:dev \
  --sub 1 \
  --email dev@example.com \
  --role admin \
  --device-id dev_abc123 \
  --tz "America/New_York" \
  --ua "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"

# Machine-readable output
zin jwt:dev --json --expires 30m --device-id dev_abc123

Operational Guidance

ConcernRecommendation
Secret storage (client)Use native secure storage: iOS Keychain, Android Keystore, Electron safeStorage. Avoid localStorage for high-risk apps.
Secret storage (server)Store device secrets hashed (HMAC) in your DB/KV, not in plain text.
Nonce storeBack the nonce replay store with Redis or KV for multi-instance / multi-region deployments.
Secret rotationUse zin key:bulletproof to rotate; old secrets are kept in BULLETPROOF_SIGNING_SECRET_BK for the duration of the replay window.
Device revocationDelete the device record and revoke all associated JWTs on compromise.
Replay windowDefault is ±60 s. Increase if clients have poor clock sync; decrease for stricter security.

Released under the MIT License.