This is the commit that adds common/crypto/ alongside the providers and virtual keys modules, and it’s where I made a decision I want to explain properly, because on the surface it looks inconsistent: Azure API keys are encrypted in a way that can be reversed, and virtual keys are hashed in a way that can’t. Both are secrets. Why treat them differently?
Azure credentials need to come back out
The gateway has to send the real Azure API key to Azure on every request. There’s no way around that, the credential has to exist in plaintext at the moment of the outbound call. So it can’t be a one-way hash, it has to be genuinely reversible encryption. I used AES-256-GCM:
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
@Injectable()
export class EncryptionService {
private readonly key: Buffer;
constructor(configService: ConfigService) {
const secret = configService.getOrThrow<string>(
'security.azureCredentialEncryptionSecret',
);
this.key = Buffer.from(secret, 'hex');
if (this.key.length !== 32) {
throw new Error(
'AZURE_CREDENTIAL_ENCRYPTION_SECRET must be a 32-byte (64 hex character) value',
);
}
}
encrypt(plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, this.key, iv);
const ciphertext = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return [iv, authTag, ciphertext]
.map((buf) => buf.toString('base64'))
.join(':');
}
decrypt(payload: string): string {
const [ivB64, authTagB64, ciphertextB64] = payload.split(':');
const iv = Buffer.from(ivB64, 'base64');
const authTag = Buffer.from(authTagB64, 'base64');
const ciphertext = Buffer.from(ciphertextB64, 'base64');
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]).toString('utf8');
}
}GCM gives me an auth tag alongside the ciphertext, so tampering with the stored value gets caught at decrypt time rather than silently producing garbage. The constructor fails loudly if AZURE_CREDENTIAL_ENCRYPTION_SECRET isn’t a proper 32-byte hex string, which is a small thing that saved me from a much worse debugging session later. I’d rather the app refuse to boot with a bad secret than boot fine and fail mysteriously on the first encrypted deployment lookup.
ModelDeployment.apiKeyEncrypted stores exactly what encrypt() returns. ProvidersService.getDecryptedCredentials is the only place that ever calls decrypt(), and it’s explicitly commented as gateway-only, never exposed through a controller response:
/** Decrypted Azure credentials for the gateway proxy; never exposed via a controller. */
async getDecryptedCredentials(id: string) {
const deployment = await this.findOrThrow(id);
return {
...deployment,
apiKey: this.encryptionService.decrypt(deployment.apiKeyEncrypted),
};
}Every response DTO for ModelDeployment builds its shape with an explicit allow-list of fields, not an exclude-list that drops apiKeyEncrypted. I did that on purpose. An allow-list means if I ever add a new sensitive field to the Prisma model and forget to think about it, the default behavior is that it doesn’t leak, because it just isn’t in the DTO. An exclude-list means the default behavior for a new field is that it leaks unless I remember to add it to the exclusion. I’d rather the safe path be the lazy path.
Virtual keys never need to come back out
A virtual key is different. Once I hand it to whatever app is going to use it, Gatify never needs the plaintext again. All it needs to do, on every gateway request, is check “does this incoming Bearer token match a key I issued.” That’s a one-way comparison, which means it should be a one-way hash, and it doesn’t need bcrypt’s deliberately-slow design either, since this check happens on the hot path of every single API call, not on a login form a human fills out once.
const KEY_PREFIX = 'gtfy';
const VISIBLE_PREFIX_LENGTH = 12;
@Injectable()
export class VirtualKeyService {
private readonly hmacSecret: string;
generate(): GeneratedVirtualKey {
const plaintextKey = `${KEY_PREFIX}_${randomBytes(32).toString('base64url')}`;
return {
plaintextKey,
keyHash: this.hash(plaintextKey),
keyPrefix: plaintextKey.slice(0, VISIBLE_PREFIX_LENGTH),
};
}
hash(plaintextKey: string): string {
return createHmac('sha256', this.hmacSecret)
.update(plaintextKey)
.digest('hex');
}
}HMAC-SHA256 with a server-side secret is fast, deterministic, and exactly matches how VirtualKeyGuard needs to use it: hash the incoming token, look it up by keyHash with a plain unique index lookup, no per-row comparison loop. If I’d used bcrypt here, every single gateway request would pay bcrypt’s cost function on a value that’s already 256 bits of randomness and doesn’t need slowing down, it needs a fast honest comparison. The gtfy_ prefix and the twelve visible characters (keyPrefix) exist purely for the dashboard, so a list of keys is recognizable to a human (“that’s the one for my Discord bot”) without ever storing or displaying the full secret again after creation.
Show it once, then never again
VirtualKeysService.create is the only place the plaintext key exists outside of the response to that single request:
async create(dto: CreateVirtualKeyDto): Promise<CreateVirtualKeyResponseDto> {
await this.assertModelsActive(dto.allowedModelIds);
const { plaintextKey, keyHash, keyPrefix } = this.virtualKeyCrypto.generate();
const virtualKey = await this.prisma.virtualKey.create({
data: {
keyHash,
keyPrefix,
label: dto.label,
maxBudget: dto.maxBudget,
budgetPeriod: dto.budgetPeriod ?? BudgetPeriod.TOTAL,
rateLimitRpm: dto.rateLimitRpm,
rateLimitTpm: dto.rateLimitTpm,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined,
allowedModels: {
create: dto.allowedModelIds.map((deploymentId) => ({ deploymentId })),
},
},
include: WITH_ALLOWED_MODELS,
});
return { ...this.toResponseDto(virtualKey), plaintextKey };
}CreateVirtualKeyResponseDto extends the normal VirtualKeyResponseDto with one extra plaintextKey field that only exists on this specific response type. Every subsequent GET for that key returns the regular DTO, which only ever has keyPrefix. The frontend later builds a modal around this exact shape: show the plaintext key once right after creation, in a dismissible dialog, and never again. If you lose it, you generate a new key. That’s the same mental model as an AWS access key, and it felt like the right one to copy.
One small bug worth mentioning because it’s the kind of thing that costs ten minutes for a silly reason: I initially wrote @Positive() on the budget and rate limit DTO fields, copying the name from memory. The actual class-validator decorator is @IsPositive(). NestJS’s validation pipe just silently ignored the field entirely rather than erroring, since @Positive isn’t a real decorator and doesn’t do anything, it’s not attached to the property at all. Found it by noticing a negative maxBudget was being accepted without complaint, which is exactly the kind of quiet failure that validation bugs love to produce.
Next: the gateway module itself, where all of this crypto actually gets used on a real request path, including the fallback logic that keys off fallbackForId from the schema.