Naalya Handbook
Secrets & Credentials

Secrets at Rest

The product side — API keys hashed with SHA-256 and shown once, the Microsoft clientState secret, and the /docs Basic Auth gate.

The config secrets are things we hold. But the API also manages secrets that users create, and the discipline there is different: we store them so we can't read them back. This page covers the two storage mechanisms and the Basic Auth gate that leans on a required secret.

API key hashing

A school can mint a long-lived API key to call us programmatically. The raw key is shown to the user exactly once, at creation, and is never stored. What we persist is a SHA-256 hash. The crypto helpers are tiny and live in one file.

apps/server/src/app/api-key/api-key.crypto.ts
import { createHash, randomBytes } from 'node:crypto';

export const API_KEY_PREFIX = 'sk_nly_';

export function generateRawApiKey(): string {
  return `${API_KEY_PREFIX}${randomBytes(32).toString('base64url')}`;
}

// SHA-256, not a slow KDF: API keys are 256-bit random and verified on every request.
export function hashApiKey(raw: string): string {
  return createHash('sha256').update(raw).digest('hex');
}

export function deriveDisplayHint(raw: string): string {
  return `${API_KEY_PREFIX}…${raw.slice(-4)}`;
}

Two design choices are worth understanding. First, why SHA-256 and not a slow password hash like bcrypt? Slow KDFs exist to defend low-entropy secrets (human passwords) against brute force. An API key here is 32 bytes of cryptographic randomness — 256 bits — so brute-forcing it is infeasible regardless of hash speed, and a fast hash lets us verify a key on every request without burning CPU. Second, deriveDisplayHint produces something like sk_nly_…aB3x — enough for a human to recognize which key a row is, while revealing nothing useful.

Hashed, shown once

When a key is minted, the service hashes the raw value, stores the hash plus the display hint, and hands the raw key back to the caller — the only moment it ever exists in the clear.

apps/server/src/app/api-key/api-key.service.ts
async mint(name: string, createdById?: string) {
  const apiKey = generateRawApiKey();
  const record = await this.apiKeyRepository.create({
    name,
    keyHash: hashApiKey(apiKey),      // only the hash is persisted
    displayHint: deriveDisplayHint(apiKey),
    createdById: createdById ?? null,
  });
  return { apiKey, record };           // raw key returned once, never stored
}

Verification on later requests hashes the incoming key the same way and looks for a matching, un-revoked row — never decrypting anything, because there's nothing to decrypt.

apps/server/src/app/api-key/api-key.service.ts
async verify(rawKey: string): Promise<ApiKeyEntity | null> {
  if (!rawKey) return null;
  const keyHash = hashApiKey(rawKey);
  // Unscoped: no tenant context exists yet — the key itself resolves the school.
  const key = await runUnscoped(() =>
    this.apiKeyRepository.findOneWhere({ keyHash, revokedAt: IsNull() }),
  );
  return key ?? null;
}

And the response mapper deliberately drops keyHash so even the hash can never leave through the API.

apps/server/src/app/api-key/api-key.mapper.ts
// Maps to the response shape — deliberately omits keyHash so it can never leak.
export function toApiKeyResponse(entity: ApiKeyEntity): ApiKeyResponseDto {
  return { id: entity.id, name: entity.name, displayHint: entity.displayHint, /* ... */ };
}

API Tokens covers the full minting and verification flow if you're building against it.

Microsoft clientState

The Microsoft 365 directory-sync webhook uses the same idea — a stored secret that an incoming request must prove it knows. When the API creates a Graph subscription for a school, it generates a fresh random clientState secret, sends it to Microsoft, and persists it against that school. Every notification Microsoft sends echoes that clientState back, and we check it before trusting the payload.

apps/server/src/app/microsoft-sync/microsoft-sync.service.ts
const clientStateSecret = randomBytes(32).toString('hex');
const subscription = await client.api('/subscriptions').post({
  changeType: CHANGE_TYPES,
  notificationUrl,
  resource: 'users',
  expirationDateTime,
  clientState: clientStateSecret,   // stored against the school, echoed back per notification
});

The comparison uses timingSafeEqual, not ===. A naive string compare returns as soon as it finds a mismatched byte, and that timing difference can leak how many leading bytes were correct — enough, over many attempts, to guess the secret. timingSafeEqual always takes the same time.

apps/server/src/app/microsoft-sync/microsoft-sync.service.ts
/** Constant-time secret comparison — avoids leaking match length via timing. */
private secretsMatch(received: string, expected: string): boolean {
  const a = Buffer.from(received ?? '');
  const b = Buffer.from(expected ?? '');
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

This clientState check is one of three layers (registered subscription, matching secret, matching tenant) before a notification is trusted — see Microsoft Sync for the rest.

The /docs gate

The interactive API docs at /docs (and the Swagger JSON behind them) are useful internally but shouldn't be open to the world in deployed environments. main.ts puts an HTTP Basic Auth gate in front of them — but only when NODE_ENV is not development, so local dev stays frictionless.

apps/server/src/main.ts
const nodeEnv = configService.get<string>('nodeEnv');
if (nodeEnv !== 'development') {
  const docsUsername = configService.getOrThrow<string>('docs.username');
  const docsPassword = configService.getOrThrow<string>('docs.password');
  app.use(
    ['/docs', '/docs/swagger', '/docs/swagger/json'],
    basicAuth({
      users: { [docsUsername]: docsPassword },
      challenge: true,
      realm: 'Naalya API Docs',
    }),
  );
}

The fail-fast boundary makes the gate safe

Because DOCS_USERNAME and DOCS_PASSWORD are required in the Zod schema, the getOrThrow calls can't return undefined in a non-dev env — the app would have failed validation at boot if they were missing. The gate can't silently fall back to "no auth" because the secrets were absent.

Where to go next

On this page