OS Trading Engine
Technical Documentation
Backend
Authentication & Authorization

Authentication & Authorization

Nexgent supports two authentication methods: JWT tokens for user sessions and API keys for programmatic access.

Authentication Methods

MethodUse CaseHeader Format
JWTDashboard, user sessionsAuthorization: Bearer <token>
API KeyProgrammatic access, webhooksX-API-Key: nex_xxx or Authorization: Bearer nex_xxx

JWT Authentication

Token Types

TypePurposeExpiry
Access TokenAPI authentication15 minutes
Refresh TokenObtain new access tokens7 days

Login Flow

┌─────────┐         ┌─────────┐         ┌─────────┐
│ Client  │         │ Backend │         │  Redis  │
└────┬────┘         └────┬────┘         └────┬────┘
     │                   │                   │
     │ POST /auth/login  │                   │
     │ {email, password} │                   │
     │──────────────────►│                   │
     │                   │                   │
     │                   │ Verify password   │
     │                   │ Generate tokens   │
     │                   │                   │
     │  {accessToken,    │                   │
     │   refreshToken}   │                   │
     │◄──────────────────│                   │
     │                   │                   │

Token Structure

// Access Token Payload
interface AccessTokenPayload {
  userId: string;
  email: string;
  type: 'access';
  jti: string;  // Unique token ID for revocation
  iat: number;  // Issued at
  exp: number;  // Expiration
}
 
// Refresh Token Payload
interface RefreshTokenPayload {
  userId: string;
  type: 'refresh';
  jti: string;
  iat: number;
  exp: number;
}

Token Generation

// src/shared/utils/auth/jwt.ts
export function generateAccessToken(user: { id: string; email: string }): string {
  const jti = randomUUID();
  return jwt.sign(
    {
      userId: user.id,
      email: user.email,
      type: 'access',
      jti,
    },
    process.env.JWT_SECRET!,
    { expiresIn: '15m' }
  );
}
 
export function generateRefreshToken(userId: string): string {
  const jti = randomUUID();
  return jwt.sign(
    {
      userId,
      type: 'refresh',
      jti,
    },
    process.env.JWT_SECRET!,
    { expiresIn: '7d' }
  );
}

Token Refresh

// POST /api/v1/auth/tokens/refresh
async function refreshTokens(req: Request, res: Response) {
  const { refreshToken } = req.body;
  
  // Verify refresh token
  const payload = verifyToken(refreshToken);
  if (payload.type !== 'refresh') {
    return res.status(401).json({ error: 'Invalid token type' });
  }
  
  // Check if refresh token is blacklisted
  const isBlacklisted = await redisTokenService.isRefreshTokenBlacklisted(payload.jti);
  if (isBlacklisted) {
    return res.status(401).json({ error: 'Token revoked' });
  }
  
  // Generate new tokens
  const user = await prisma.user.findUnique({ where: { id: payload.userId } });
  const newAccessToken = generateAccessToken(user);
  const newRefreshToken = generateRefreshToken(user.id);
  
  // Blacklist old refresh token (one-time use)
  await redisTokenService.blacklistRefreshToken(payload.jti, payload.exp);
  
  res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
}

Token Revocation

Tokens are revoked by adding their jti to a Redis blacklist:

// src/infrastructure/cache/redis-token-service.ts
class RedisTokenService {
  async blacklistAccessToken(jti: string, expiresAt: number): Promise<void> {
    const ttl = expiresAt - Math.floor(Date.now() / 1000);
    if (ttl > 0) {
      await redisService.set(`token:blacklist:access:${jti}`, '1', ttl);
    }
  }
  
  async isAccessTokenBlacklisted(jti: string): Promise<boolean> {
    return await redisService.exists(`token:blacklist:access:${jti}`);
  }
}

Blacklisted tokens are stored with TTL matching their expiration. Once the token would expire naturally, the blacklist entry is automatically removed.


API Key Authentication

API keys provide programmatic access without user sessions.

Key Format

nex_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  • Prefix: nex_ (identifies as Nexgent API key)
  • Body: 32 random alphanumeric characters
  • Total: 36 characters

Key Storage

Keys are hashed before storage (raw key shown only once at creation):

// src/shared/utils/api-keys/generate.ts
export async function generateApiKey(): Promise<{ raw: string; hash: string; prefix: string }> {
  const raw = `nex_${crypto.randomBytes(24).toString('base64url')}`;
  const hash = await bcrypt.hash(raw, 10);
  const prefix = raw.substring(0, 12); // "nex_xxxxxxxx"
  
  return { raw, hash, prefix };
}
model ApiKey {
  id        String   @id @default(uuid())
  userId    String
  name      String
  keyHash   String   @unique  // bcrypt hash
  keyPrefix String            // First 12 chars for identification
  scopes    String[]          // Permission scopes
  createdAt DateTime @default(now())
}

Key Verification

// src/shared/utils/api-keys/verify.ts
export async function verifyApiKey(rawKey: string): Promise<ApiKeyData | null> {
  // Extract prefix for efficient lookup
  const prefix = rawKey.substring(0, 12);
  
  // Find key by prefix
  const apiKey = await prisma.apiKey.findFirst({
    where: { keyPrefix: prefix },
    include: { user: true },
  });
  
  if (!apiKey) return null;
  
  // Verify hash
  const isValid = await bcrypt.compare(rawKey, apiKey.keyHash);
  if (!isValid) return null;
  
  return {
    id: apiKey.id,
    userId: apiKey.userId,
    email: apiKey.user.email,
    scopes: apiKey.scopes,
  };
}

Scopes

API keys can be restricted to specific operations:

ScopePermissions
signals:writeCreate trading signals
signals:readRead trading signals
agents:readRead agent data
agents:writeModify agents
// Check scope in middleware
export function authenticateApiKeyWithScope(requiredScope: string) {
  return async (req, res, next) => {
    const keyData = await verifyApiKey(extractApiKey(req));
    
    if (!hasScope(keyData.scopes, requiredScope)) {
      return res.status(403).json({
        error: `API key missing required scope: ${requiredScope}`,
      });
    }
    
    next();
  };
}

Password Security

Hashing

Passwords are hashed using bcrypt with cost factor 12:

// src/shared/utils/auth/password.ts
export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, 12);
}
 
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

Account Lockout

Failed login attempts trigger account lockout:

// src/shared/utils/auth/account-lockout.ts
const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
 
export async function handleFailedLogin(user: User): Promise<void> {
  const failedAttempts = user.failedLoginAttempts + 1;
  
  if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
    await prisma.user.update({
      where: { id: user.id },
      data: {
        failedLoginAttempts: failedAttempts,
        lockedUntil: new Date(Date.now() + LOCKOUT_DURATION),
      },
    });
  } else {
    await prisma.user.update({
      where: { id: user.id },
      data: { failedLoginAttempts: failedAttempts },
    });
  }
}
 
export async function isAccountLocked(user: User): Promise<boolean> {
  if (!user.lockedUntil) return false;
  return user.lockedUntil > new Date();
}

Wallet Security

Private keys are never stored in the database.

Key Loading

Wallets are loaded from environment variables at startup:

// src/infrastructure/wallets/wallet-loader.ts
export class WalletLoader {
  loadWallets(): { wallets: Map<string, Keypair>; errors: Error[] } {
    const wallets = new Map<string, Keypair>();
    const errors: Error[] = [];
    
    // Load WALLET_1, WALLET_2, etc. from environment
    for (let i = 1; i <= 10; i++) {
      const envKey = `WALLET_${i}`;
      const privateKey = process.env[envKey];
      
      if (privateKey) {
        try {
          const keypair = Keypair.fromSecretKey(bs58.decode(privateKey));
          wallets.set(keypair.publicKey.toBase58(), keypair);
        } catch (error) {
          errors.push({ envKey, error: 'Invalid private key format' });
        }
      }
    }
    
    return { wallets, errors };
  }
}

Key Storage

Keys are stored only in memory:

// src/infrastructure/wallets/wallet-store.ts
class WalletStore {
  private wallets: Map<string, Keypair> = new Map();
  
  initialize(wallets: Map<string, Keypair>): void {
    this.wallets = wallets;
  }
  
  getKeypair(walletAddress: string): Keypair | undefined {
    return this.wallets.get(walletAddress);
  }
}
⚠️

Never log or expose private keys. The wallet store only returns keypairs for internal trade signing.


Environment Variables

# JWT
JWT_SECRET=your-secret-key-minimum-32-characters

# Database
DATABASE_URL=postgresql://user:pass@localhost:5432/nexgent

# Wallets (base58 encoded private keys)
WALLET_1=your-base58-private-key
WALLET_2=another-base58-private-key

Security Best Practices

  1. Short-lived access tokens (15 min) limit exposure window
  2. Refresh token rotation prevents token reuse
  3. Token blacklisting enables immediate logout
  4. API key hashing protects keys at rest
  5. Account lockout prevents brute force attacks
  6. Non-custodial wallets - keys never touch the database