Authentication & Authorization
Nexgent supports two authentication methods: JWT tokens for user sessions and API keys for programmatic access.
Authentication Methods
| Method | Use Case | Header Format |
|---|---|---|
| JWT | Dashboard, user sessions | Authorization: Bearer <token> |
| API Key | Programmatic access, webhooks | X-API-Key: nex_xxx or Authorization: Bearer nex_xxx |
JWT Authentication
Token Types
| Type | Purpose | Expiry |
|---|---|---|
| Access Token | API authentication | 15 minutes |
| Refresh Token | Obtain new access tokens | 7 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:
| Scope | Permissions |
|---|---|
signals:write | Create trading signals |
signals:read | Read trading signals |
agents:read | Read agent data |
agents:write | Modify 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-keySecurity Best Practices
- Short-lived access tokens (15 min) limit exposure window
- Refresh token rotation prevents token reuse
- Token blacklisting enables immediate logout
- API key hashing protects keys at rest
- Account lockout prevents brute force attacks
- Non-custodial wallets - keys never touch the database