Naalya Handbook

API Tokens

How API keys let a machine call the API as a school — the sk_nly_ token, the guard, and the @ApiKeyAuth() decorator that scopes an endpoint.

Almost every endpoint in this API expects a logged-in human: a request arrives with a JWT in the Authorization header, the JWT guard validates it, and the permission guards check what that user is allowed to do. But some callers aren't people. A school's public website needs to POST enquiry forms straight into the system. A nightly script needs to push data in. These callers have no login session and no JWT — so they can't go through the normal door.

An API key is the door they use instead. It's a single long secret string — like a password, but for a machine — that a school generates once and hands to an integration. The integration sends it on every request in a header, the server recognizes it, figures out which school the key belongs to, and lets the request through scoped to that tenant. No user, no session, just a key.

This page traces the whole mechanism from the real code in apps/server/src/app/api-key: what the key actually is, how it's stored so a database leak doesn't hand out working keys, the guard that validates an incoming key, and the @ApiKeyAuth() decorator that's all you need to open an endpoint to key-based callers. Then we walk through protecting your own endpoint, and creating and managing keys through the controller.

The mental model: a key is a tenant, not a user

A JWT says "I am Jane, a teacher at Springfield, here's what I can do." An API key says only "I belong to Springfield." It carries no permissions and no identity beyond the school. That single difference drives everything below: because a key proves so little, an endpoint that accepts one has to do its own authorization, and the audit trail attributes actions to the key, not a person.

For the JWT side of the story — the guards an API key deliberately steps around — see Auth & Permissions.


What a key is

The raw key is a prefixed random string. Generation lives in one tiny file, and the shape is worth memorizing because you'll recognize it in logs and bug reports:

apps/server/src/app/api-key/api-key.crypto.ts
export const API_KEY_PREFIX = 'sk_nly_';

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

So a real key looks like sk_nly_8Kf… — the sk_nly_ prefix (think "secret key, Naalya") followed by 32 bytes of cryptographic randomness. The prefix is purely a human convenience: it makes keys greppable and instantly recognizable, and lets secret-scanning tools spot one if it's ever pasted somewhere public. The 32 random bytes are the actual secret. With 256 bits of entropy, nobody is guessing or brute-forcing one.

Hashed at rest

Here's the most important security decision in the module: the raw key is never stored. What goes into the database is a hash of it. When a request comes in, the server hashes the incoming key the same way and looks for a row with a matching hash.

apps/server/src/app/api-key/api-key.crypto.ts
// 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 things deserve a sentence. First, the comment is doing real work: passwords use a deliberately slow hash (bcrypt, argon2) because humans pick guessable passwords and you want brute-forcing to be expensive. An API key is already 256 bits of randomness, so there's nothing to brute-force — a fast SHA-256 is the right tool, and it's fast enough to run on every single request without slowing things down. Second, deriveDisplayHint produces something like sk_nly_…a4f9 — a non-secret label so a dashboard can show "which key is this" without ever revealing the secret.

A leaked database does not leak working keys

Because only the hash is stored, an attacker who dumps the api_key table gets a list of SHA-256 hashes — useless for authenticating. They can't reverse a hash back into a key. This is the whole reason for hashing at rest, and it's why the raw key is shown to the user exactly once and never again (more on that in Gotchas).

The api_key entity

Each key is a row in the api_key table. The entity is tenant-scoped (every row belongs to a school) and timestamped. Notice what it stores and — just as important — what it doesn't:

apps/server/src/app/api-key/entities/api-key.entity.ts
@Entity('api_key')
@TenantScoped()
@WithTimestamps()
export class ApiKeyEntity extends DatabaseEntity implements ApiKey {
  @Column({ name: 'school_id', type: 'uuid' })
  schoolId: string;

  @Column()
  name: string;

  @Column({ name: 'key_hash', unique: true })
  keyHash: string;

  @Column({ name: 'display_hint' })
  displayHint: string;

  @Column({ name: 'last_used_at', type: 'timestamp', nullable: true })
  lastUsedAt?: Date | null;

  @Column({ name: 'revoked_at', type: 'timestamp', nullable: true })
  revokedAt?: Date | null;
  // ... created_by_id
}

The keyHash is unique — it's the lookup column, so two keys can never collide. schoolId is what makes a key mean a tenant. revokedAt is a soft off-switch: set it, and the key stops working without deleting the history. lastUsedAt gets a timestamp every time the key authenticates, so a dashboard can show "last seen" and flag keys that look abandoned. There is no keyHash-adjacent column holding the real secret, because the real secret was never persisted.

The @TenantScoped() decorator (covered in Multi-tenancy) means ordinary queries against this table are automatically filtered to the current school — which matters a lot when you list keys, and matters in a subtle way when you verify one, as we'll see next.


The guard

A guard in NestJS is a small class that runs before a route handler and answers one yes/no question: should this request be allowed through? It returns true to continue or throws to reject. The JWT guard you've met elsewhere is one; ApiKeyGuard is its API-key counterpart.

When a request hits a key-protected route, ApiKeyGuard.canActivate runs. Trace it top to bottom — every line earns its place:

apps/server/src/app/api-key/guards/api-key.guard.ts
export const API_KEY_HEADER = 'x-api-key';

@Injectable()
export class ApiKeyGuard implements CanActivate {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest<Request>();
    const rawKey = this.extractKey(request);
    if (!rawKey) throw new UnauthorizedException('API key required.');

    const key = await this.apiKeyService.verify(rawKey);
    if (!key) throw new UnauthorizedException('Invalid API key.');

    // Pin the tenant for the rest of the request's async chain.
    const ctx = tenantContextStore.getStore();
    if (ctx) {
      ctx.schoolId = key.schoolId;
      ctx.isPlatformOperator = false;
      ctx.bypass = false;
    }

    // Expose the key to the audit layer (public requests carry no JWT actor).
    (request as Request & { apiKey?: ApiKeyRequestContext }).apiKey = {
      id: key.id,
      name: key.name,
    };

    this.apiKeyService.touchLastUsed(key.id);
    return true;
  }
  // extractKey reads request.headers['x-api-key']
}

Step by step. It pulls the raw key out of the x-api-key header (Express lowercases header names, so the constant is lowercase even though clients send X-API-Key). No header means an instant 401. Then it calls verify, which hashes the key and looks for a matching, non-revoked row; no match is also a 401 — and the two messages ("API key required" vs "Invalid API key") are deliberately distinct so a developer integrating against the API can tell missing from wrong.

The next two blocks are where an API key request becomes indistinguishable from any other request to the rest of the system. First it pins the tenant: it writes the key's schoolId into the request-scoped tenant context (the AsyncLocalStorage-backed store that every database query reads to scope itself — see Multi-tenancy). This mirrors exactly what the JWT guard does for a logged-in user. From this line on, queries in the handler are automatically scoped to this key's school, even though no human is logged in.

Second, it attaches the key to the request under request.apiKey. There's no JWT, so the audit layer has no req.user to point at — without this, an action taken via an API key would have a blank actor. We come back to exactly how the audit layer uses this in a moment.

Finally touchLastUsed stamps the row — fire-and-forget, so a slow write here never delays the request.

Unscoped verify

There's one subtle line in the service worth slowing down for. Verification cannot use the normal tenant-scoped query, and the reason is a bootstrapping problem:

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;
}

At the moment verify runs, the tenant context is still empty — the guard hasn't pinned a schoolId yet, because the key is what tells us the school. If this query were tenant-scoped, it would filter by a schoolId we don't have, and find nothing. runUnscoped lifts that filter for this one lookup so it can search all keys by hash. The revokedAt: IsNull() clause is what makes revocation instant — a revoked key has a timestamp there, so it simply won't match. This is the only place tenant scoping is intentionally bypassed, and the comment exists precisely so nobody "fixes" it by removing runUnscoped.


The @ApiKeyAuth() decorator

You almost never touch the guard directly. The whole public surface of this feature is one decorator, and it bundles three separate jobs into a single line:

apps/server/src/app/api-key/decorators/api-key-auth.decorator.ts
export function ApiKeyAuth() {
  return applyDecorators(
    Public(),
    UseGuards(ApiKeyGuard),
    ApiHeader({
      name: 'X-API-Key',
      required: true,
      description: 'School-issued API key that scopes the request to a tenant.',
    }),
  );
}

Each line does one thing:

  1. Public() marks the route as public. This is the critical move — without it, the global JWT and permission guards would run first and reject the request for having no JWT, long before ApiKeyGuard ever got a chance. Public() sets the IS_PUBLIC_KEY metadata that those global guards read; when they see it, they stand down and let the request pass.
  2. UseGuards(ApiKeyGuard) binds the API-key guard to this route. So the global guards step aside, and in their place ApiKeyGuard does the validating.
  3. ApiHeader(...) adds the X-API-Key header to the Swagger docs for this endpoint, so anyone reading the API reference knows to send it.

It helps to see why Public() is enough to make the global guards back off. The JWT guard reads the same metadata key and short-circuits to true the moment it's set:

apps/server/src/app/auth/guards/jwt-auth.guard.ts
canActivate(context: ExecutionContext) {
  const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
    context.getHandler(),
    context.getClass(),
  ]);
  if (isPublic) return true;        // ← JWT guard stands down

  return super.canActivate(context); // normal JWT validation otherwise
}

The permission guard does the same. So the order on a key-protected route is: global guards see "public" and wave it through → ApiKeyGuard actually validates the key and pins the tenant → your handler runs.

Public means 'no JWT', not 'no auth'

@ApiKeyAuth() makes the route @Public(), which switches off the permission system entirely for that route. A valid key gets you in, but nothing checks whether this key is allowed to do this specific thing — there is no per-key permission check. Authorization beyond "is the key valid for this tenant" is your handler's job. Keep key-protected endpoints narrow and single-purpose.


Audit attribution

When an action happens over an API key, you still want a record of it — and that record needs to say which key did it, since there's no human actor. This is exactly why the guard stashed request.apiKey. The audit interceptor (see Auditing) reads req.user for a normal request, but for a key request req.user is undefined — so the endpoint is responsible for surfacing the key into the audit context.

The live example is the enquiry endpoint, the one real consumer of @ApiKeyAuth() today. Its @Audit block pulls the key off the request and records its id and name as context:

apps/server/src/app/enquiry/enquiry.controller.ts
@Post()
@ApiKeyAuth()
@Audit({
  action: ActionAuditAction.CREATE,
  resource: AuditResourceType.ENQUIRY,
  labelField: 'name',
  afterFrom: (r) => enquirySnapshot(r as Partial<EnquiryEntity>),
  context: (ctx) => {
    const req = ctx.switchToHttp().getRequest<Request>();
    const apiKey = (req as Request & { apiKey?: ApiKeyRequestContext }).apiKey;
    return apiKey ? { apiKeyId: apiKey.id, apiKeyName: apiKey.name } : {};
  },
})
submit(@Body() body: CreateEnquiryDto) {
  return this.enquiryService.create(body);
}

So when a school website submits an enquiry, the audit log shows the action's schoolId (pinned by the guard) and an audit context of { apiKeyId, apiKeyName } — enough to answer "which integration created this?" after the fact. That ApiKeyRequestContext type — { id, name } — is the exact shape the guard wrote, exported from the guard file so consumers stay in lockstep with it.


Step 1: Protect

Say you're building a second machine-to-machine endpoint — a school's website needs to push something in, no human logged in. Here's the whole recipe; it's short because the decorator does the heavy lifting.

First, add @ApiKeyAuth() to the handler. That's the only thing required to make the route accept a key instead of a JWT:

apps/server/src/app/your-feature/your-feature.controller.ts
@Post()
@ApiKeyAuth()
@ApiOperation({
  summary: 'Ingest something from the school website',
  description: 'The tenant is resolved from the X-API-Key header.',
})
ingest(@Body() body: CreateSomethingDto) {
  // Tenant is already pinned by the guard — normal scoped queries Just Work.
  return this.service.create(body);
}

You do not inject anything, read any header, or look up the school yourself. By the time ingest runs, the guard has already validated the key and pinned the tenant, so any scoped query inside service.create is automatically filtered to the right school.

Step 2: Validate input

Because the route is @Public(), the permission system is off — so be conservative. This endpoint should do exactly one thing, accept a tightly-validated DTO, and never expose data back out. A write-only "create" is the safe shape; a key-protected GET that returns records is a data-leak waiting to happen if a key escapes. Treat the DTO's class-validator rules as your only gate on the request body.

Step 3: Attribute

If the endpoint does anything audit-worthy, add an @Audit block and surface the key into its context, copying the enquiry pattern verbatim so the audit trail names the integration:

apps/server/src/app/your-feature/your-feature.controller.ts
@Audit({
  action: ActionAuditAction.CREATE,
  resource: AuditResourceType.SOMETHING,
  context: (ctx) => {
    const req = ctx.switchToHttp().getRequest<Request>();
    const apiKey = (req as Request & { apiKey?: ApiKeyRequestContext }).apiKey;
    return apiKey ? { apiKeyId: apiKey.id, apiKeyName: apiKey.name } : {};
  },
})

Import ApiKeyRequestContext from ../api-key/guards/api-key.guard. Without this context callback the action is still audited, but it can't say which key did it.

Step 4: Test

The guard already has its own coverage (see api-key.guard.spec.ts). For your endpoint, the thing worth a test is that the route is reachable with a valid key and rejected without one — mirror the existing specs and assert a 401 when the x-api-key header is missing. See Testing for the harness conventions.


Step 5: Manage keys

Keys themselves are managed by a normal, JWT-protected controller — only a logged-in admin with the right permission can mint or revoke a key for their school. This is the mirror image of the endpoints above: the management surface is locked down with the full permission system, even though the keys it produces bypass it.

Here's the API surface. Notice every route carries @RequirePermissions on Resource.API_KEY — this is the per-action authorization the key-protected routes don't have:

RouteOperationPermission
POST /api-keysMint a new keyCREATE / API_KEY
GET /api-keysList the school's keysLIST / API_KEY
PATCH /api-keys/:id/revokeRevoke (disable) a keyUPDATE / API_KEY
DELETE /api-keys/:idSoft-delete a keyDELETE / API_KEY

The create path is the one that matters most, because it's the only moment the raw key exists outside the integration. The service mints a key, and the controller returns it — once:

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

The controller takes that { apiKey, record } and merges the raw key into the response — using a dedicated CreateApiKeyResponseDto whose apiKey field is documented as shown once and never again:

apps/server/src/app/api-key/api-key.controller.ts
@Post()
@RequirePermissions({ action: Action.CREATE, resource: Resource.API_KEY })
async create(@Body() body: CreateApiKeyDto, @CurrentUser('sub') userId: string) {
  const { apiKey, record } = await this.apiKeyService.mint(body.name, userId);
  return { ...toApiKeyResponse(record), apiKey };
}

Every other read goes through toApiKeyResponse, the mapper that deliberately omits keyHash so the secret material can never leak through the list or the create response's other fields. List, revoke, and delete all return that safe shape and never the raw key.

Revoke disables, delete forgets — prefer revoke

revoke stamps revokedAt, so verify's revokedAt: IsNull() filter stops matching it immediately — the key dies but its row (and audit history) survives. delete soft-deletes the row entirely. The controller's own docstring says it: prefer revoke to disable a key while keeping its record. Reach for delete only when you want the key gone from the list for good.


Gotchas

A few traps catch people the first time they work with API keys here.

The raw key is shown exactly once

mint is the only place the raw sk_nly_… string ever exists server-side, and create is the only response that contains it. The database stores just the hash, so the server cannot show you the key again — there's nothing to show. If the integration loses it, there is no "resend"; you revoke the old key and mint a new one. Make sure whatever UI calls create makes the user copy the key right then.

@ApiKeyAuth() turns the permission system OFF

Because the decorator marks the route @Public(), neither the JWT guard nor the permission guards run. A valid key for the tenant is the only check. There is no per-key scope, no @RequirePermissions enforcement, nothing. Any authorization beyond "valid key for this school" has to live in your handler — so keep these routes write-only and single-purpose, and never put one on an endpoint that reads or lists data.

The header is X-API-Key, and the prefix is not optional

Clients send the key in the X-API-Key header (Express sees it as lowercase x-api-key). Not Authorization, not a bearer token, not a query param. And the key includes its sk_nly_ prefix — the prefix is part of the secret that gets hashed, so sending the bytes without it produces a different hash and a 401.

Don't 'fix' runUnscoped in verify

verify runs its lookup unscoped on purpose: at that point no school is known yet, because the key is what reveals it. If you make that query tenant-scoped, it'll filter by an empty schoolId and authentication silently breaks for everyone. The comment in the file is a warning sign, not a leftover.


Where to go next

On this page