API tokens
Section titled “API tokens”Bridge lets you offer your own users a self-service way to create API tokens for programmatic access to your API: the same idea as a GitHub or Stripe personal access token, without you having to build token issuance, storage, or revocation yourself. Creating and managing tokens is self-service UI territory (a drop-in component on the frontend); bridge-nestjs’s job is the other end: verifying a token when it shows up on a request and exposing its claims to your guards.
How it works
Section titled “How it works”- Privilege-scoped: a token is created with an explicit set of privileges (the same privilege keys your roles use), picked from a searchable list. It can never do more than what it’s granted.
- Workspace-scoped: a token is bound to the workspace it was created in (a workspace is called a tenant in the API) and can’t be replayed against another one.
- Hash-at-rest, shown once: Bridge stores only a salted hash. The full token value is shown exactly once, right after creation; copy it straight into your secret manager, because Bridge can never display it again. If it’s lost, revoke it and issue a new one.
- Revocation: backend SDKs verify API tokens by asking Bridge (introspection) rather than checking a local signature, and by default they do this on every request, so a revoked token is rejected on its very next call. If your backend enables introspection-result caching, rejection can lag by up to that cache’s TTL.
How a token reaches your app
Section titled “How a token reaches your app”Tokens are sent as x-api-key, evaluated as an independent path from the Authorization: Bearer (user JWT) path; see Route guards for where this sits in the overall guard flow. Two ways BridgeAuthGuard ends up with a verified token on request.bridgeApiToken:
- Pre-processed: if something upstream (Bridge’s own
bridge-apigateway, in front of first-party services) already verified the key and setrequest.bridgeApiToken, the guard trusts it as-is and does not re-verify. - Introspection: otherwise, if the
x-api-keyvalue is JWT-shaped (three non-empty, dot-separated segments), the guard verifies it by POSTing it to Bridge’s introspection endpoint. API tokens are signed with a per-app secret your app never holds, so they can’t be verified locally the way user JWTs are (see Logging in and logging out for the user-JWT model); instead, Bridge itself checks the signature and the backing record and answers with the token’s claims. This is the path a customer NestJS app not sitting behindbridge-apiuses. - Anything else falls through silently: a non-JWT-shaped
x-api-key(an opaque, legacy-style key) produces nobridgeApiTokencontext at all. The guard doesn’t error on it; it just behaves as if that header weren’t there, and the request has to succeed some other way (a validAuthorization: Bearer) or it’s rejected for having no credential at all.
What’s on a verified token
Section titled “What’s on a verified token”interface ApiTokenClaims {
sub: string;
appId: string;
tenantId: string | null;
type: 'api';
privileges: string[];
}
privileges: the exact set the token was created with; it can never do more than this list allows. This is the same privilege-key vocabulary your roles use (USER_READ,TENANT_WRITE, or a custom key).tenantId:nullfor an app-level token not tied to a specific workspace; a real ID for a workspace-scoped token. See Multi-tenancy for what that means for your endpoints.type: 'api': verified explicitly; a token missing this or carrying the wrong value fails withTOKEN_INVALIDeven if it’s otherwise active (guards against a user-JWT-shaped token being replayed on the API-token path).appIdmust match your app’s configuredappIdexactly, or verification fails withAPP_MISMATCH: a token minted for a different Bridge app is rejected outright, even when Bridge reports it active.
Read it directly when @RequirePrivilege() isn’t enough on its own:
import { Controller, Get, Req } from '@nestjs/common';
import { Request } from 'express';
@Controller('reports')
export class ReportsController {
@Get()
list(@Req() req: Request) {
const privileges = req.bridgeApiToken?.privileges ?? [];
// ...
}
}
Privilege enforcement is API-token-only
Section titled “Privilege enforcement is API-token-only”@RequirePrivilege(privilege) checks only req.bridgeApiToken.privileges. It has no effect on a user-JWT-only request; user JWTs bypass it entirely (existing backward-compatibility behavior). This is the single most important thing to get right about this decorator, and it’s the flip side of @RequireRole(), which checks only the user JWT’s role and is a no-op for API-token-only requests. See How roles & privileges work for the full comparison table.
import { Controller, Get, UseGuards } from '@nestjs/common';
import { BridgeAuthGuard, RequirePrivilege } from '@nebulr-group/bridge-nestjs';
@Controller('users')
@UseGuards(BridgeAuthGuard)
export class UsersController {
@Get()
@RequirePrivilege('USER_READ')
listUsers() { /* … */ }
}
An empty privileges: [] array on a token still passes the guard’s authentication step (it’s an active, correctly-typed, correctly-scoped token); it’s @RequirePrivilege() specifically that then rejects it with a 403.
Restricting which credential type an endpoint accepts
Section titled “Restricting which credential type an endpoint accepts”@AcceptAuth('jwt' | 'api_token' | 'both') restricts a route to one credential type; 'both' (the default) accepts either:
import { Controller, Get, UseGuards } from '@nestjs/common';
import { BridgeAuthGuard, AcceptAuth } from '@nebulr-group/bridge-nestjs';
@Controller('account/api-token/me')
@AcceptAuth('jwt') // this endpoint only makes sense for a signed-in person
@UseGuards(BridgeAuthGuard)
export class ApiTokenUserController { /* … */ }
When a caller sends both headers at once (first-party Bridge frontends like cloud-views always do), both are verified independently and both contexts end up on the request (bridgeApiToken and bridgeUser/bridgeTenant/bridgeAccessToken all coexisting). @AcceptAuth('jwt') only rejects a request when the API token is the only credential offered, not when it’s present alongside a valid JWT.
Revocation and caching
Section titled “Revocation and caching”Because verification is introspection (Bridge re-checks the backing token record on every uncached call), revocation is effectively immediate by default: a caller presenting a revoked token gets a 401 on its next request. The one knob that trades this away is introspectionCacheTtlMs (see Configuration): when set above 0, a successful introspection result is cached per token for that long, so a just-revoked token can keep working for up to the TTL. The default is 0 (no caching, instant revocation). One caveat that stays regardless: if your app sits behind bridge-api middleware that pre-populates request.bridgeApiToken, revocation latency is whatever that upstream verifier provides, since the guard trusts the pre-processed claims as-is.
Worked example
Section titled “Worked example”import { Controller, Get, INestApplication, UseGuards } from '@nestjs/common';
import { BridgeAuthGuard, RequirePrivilege } from '@nebulr-group/bridge-nestjs';
@Controller('api-token-test')
class ApiTokenTestController {
@Get('protected')
@UseGuards(BridgeAuthGuard)
protected() {
return { ok: true };
}
@Get('privileged')
@UseGuards(BridgeAuthGuard)
@RequirePrivilege('USER_READ')
privileged() {
return { ok: true };
}
}
x-api-key: <token with USER_READ>→GET /api-token-test/privileged→200x-api-key: <token without USER_READ>→403x-api-key: <token with an appId for a different app>→401x-api-key: <revoked token>→401(introspection reports it inactive)Authorization: Bearer <user JWT, no privileges claim at all>→GET /api-token-test/privileged→200(user JWTs bypass@RequirePrivilege)