The product meeting ends on a vote. Web and mobile will call the API with a JWT. The billing partner will call the same API with an API key. Someone asks which mechanism is “the right one.” The room treats that as a fork.

It is not a fork. The web app has a signed-in user who must be authorized to read their own invoices. The partner has no user in your identity store. It is a consumer you need to identify, meter, and throttle. Those are different jobs. Forcing them onto one credential is how teams ship a 30-day JWT that is really a password, or an API key that is really a session.

“JWT or API Key?” is the wrong question. The useful one is: what kind of client do you have, what do you need to identify, what do you need to authorize, and what is your trust model?

JWT and API keys are not mandatory alternatives. They can sit in the same architecture because they can solve different problems.

flowchart TD
    api["API"]

    api --> endUser["End user"]
    api --> application["Application"]

    endUser --> access["Access Token<br/>(possibly a JWT)"]
    application --> apiKey["API Key"]

    access --> backend["Backend API"]
    apiKey --> backend

That diagram is an architecture choice, not a standard. RFC 7519 does not tell you to put JWT on users. OWASP does not tell you to put API keys on partners. The split follows from the consumers.

Authentication is not authorization

Teams collapse “logged in” into one blob and then argue about token formats. The names look related. The jobs are not.

flowchart TD
    identity["Who are you?"] --> authentication["Authentication"]
    permission["What can you do?"] --> authorization["Authorization"]
Term Job
Identity The subject you are talking about: a user, a service, a partner application
Credential The secret or token presented to prove something about that subject, or about a grant
Authentication Establishing who (or which client) is speaking
Authorization Deciding what that subject is allowed to do on this request
Access token A credential that represents an authorization issued to a client
Claim A name/value assertion inside a JWT: who issued it, who it is about, when it expires

user-123 and partner-acme are both identities. They are not the same kind of principal. The credential on the wire (password, API key, bearer token) is evidence, not the identity. OWASP API2:2023 is explicit that OAuth is not authentication, and neither are API keys: OAuth issues an authorization; an API key identifies a client. Authorization is a later question — scopes, roles, ownership — and can fail with HTTP 403 after a successful 401 path.

An access token is RFC 6749 §1.4: “a string representing an authorization issued to the client.” It is usually opaque to the client. It may be a lookup key, or it may self-contain the grant as a JWT. The RFC does not require a JWT. A claim is RFC 7519: an assertion about a subject. sub, aud, scope are claims in a JWT. They are not a property of API keys.

If you treat authentication as authorization, you issue a token that only means “this request arrived.” If you treat authorization as authentication, you stuff permissions into a long-lived secret. Both show up as “we picked JWT” or “we picked API keys” when the jobs were never named.

What a JWT actually is

RFC 7519 defines JSON Web Token: a compact, URL-safe means of representing claims to be transferred between two parties. JWT is a token format. It is not an authentication system. It does not define login, logout, sessions, or revocation. A JWT that validates successfully remains valid until it is rejected.

The common compact form of a signed JWT is three base64url segments: header.payload.signature.

Header (JOSE header) names the algorithm (alg) and, often, a type (typ). RFC 8725 requires the verifier to decide which algorithms are acceptable. Do not let the token pick alg for you. The none algorithm exists in the spec as an unsecured JWT. OWASP’s REST Security Cheat Sheet says not to accept it for access control.

Payload is the claims set. Signature (for JWS) protects the header and payload. It provides integrity protection and proof that the token was signed by the holder of the signing key. It does not encrypt the payload. Anyone who has the token can read the claims. Signed (JWS) is not encrypted (JWE). Base64url is encoding, not encryption. Most access tokens you will see in a NestJS API are signed, not encrypted. TLS protects the data in transit. The JWT payload itself is still readable by anyone who possesses the token.

Registered claims in RFC 7519 §4.1 are optional. Applications define which they require. For an API, the ones that matter are:

Claim Why an API cares
iss Who minted it. Bind verification keys to that issuer (RFC 8725 §3.8).
sub Which principal the grant is about.
aud Which API may accept it. A token for billing is not a token for admin (RFC 8725 §3.9).
exp When the credential stops being acceptable. OWASP REST also wants nbf checked for access control.

jti can identify a token for replay protection or a denylist; the RFC does not implement either. scope is not a registered RFC 7519 claim. If you put "scope": "orders:read" in a JWT, that is an application or profile choice — RFC 9068 is one such profile for OAuth access tokens.

{
  "sub": "user-123",
  "iss": "https://auth.example.com",
  "aud": "orders-api",
  "exp": 1780000000,
  "scope": "orders:read"
}

Validation, in outline: parse the compact form, confirm alg is in your allowlist, verify the signature with keys you already trust, then apply iss, aud, exp, and anything else you defined. RFC 8725 adds: do not follow jku/x5u URLs from the header, and reject alg: none unless you explicitly asked for an unsecured JWT.

What an API key actually is

An API key is a credential issued to a client — usually an application, a partner, or a developer account — and presented on requests so the server can identify that consumer. There is no IETF document titled “API Keys” that defines claims, scopes, or expiration the way RFC 7519 defines JWT.

OWASP REST treats API keys as a way to reduce farming of public REST services, to meter paid plans, and to take some of the edge off denial-of-service. It tells you to require the key on protected endpoints, to return 429 Too Many Requests when the client is too fast, to revoke the key if the client violates the usage agreement, and not to rely exclusively on API keys to protect sensitive, critical, or high-value resources.

Google Cloud’s API key best practices treat keys as bearer credentials: whoever holds the string can use it. Restrict each key. Do not put it in query parameters. Do not commit it to a repository. Rotate it. Delete keys you are not using.

Typical jobs: identify an application or partner; gate access at a coarse level; rate limit; enforce quotas; attribute usage for billing. An API key does not inherently carry end-user identity, claims, scopes, expiration, or fine-grained authorization. You can hang those on a database row (expiresAt, a plan, allowed routes). Those are properties of your system, not of “API Key” as a concept.

OWASP API2:2023 says API keys should not be used for user authentication. They should be used for API client authentication.

The comparison is not symmetric

JWT and API keys are not two equivalent technologies you must always choose between. One is a way to encode claims. The other is a secret you issue to a consumer.

OAuth 2.0 sits on a different axis.

flowchart TD
    oauth["OAuth 2.0"]
    access["Access Token"]
    jwt["JWT"]
    opaque["Opaque Token"]

    oauth --> access
    access -->|May be| jwt
    access -->|May be| opaque

OAuth 2.0 ≠ JWT. RFC 6749 is an authorization framework. It issues access tokens. RFC 6750 describes how to send a bearer access token in HTTP — typically Authorization: Bearer .... Bearer means possession is enough. The RFCs do not require that the bearer string be a JWT.

JWT ≠ OAuth 2.0. RFC 7519 is a claims format. You can sign a JWT in NestJS with @nestjs/jwt and never speak OAuth. You can run OAuth with opaque tokens and introspection and never mint a JWT. RFC 9068 is a profile for when an OAuth access token is a JWT (typ should be at+jwt). A profile is not identity between the two specs.

An ID Token in OpenID Connect is a JWT used to represent the user to a client. That is still not “JWT = login,” and it is not an API key.

Characteristic JWT API Key
Nature Token format Credential
Claims Yes Not inherent
Expiration Can be represented with exp Depends on the implementation
User identity Can be represented (sub and other related claims) Not necessarily
Scopes Can represent them Not inherent
Rate limiting Can help identify a subject Common use
Revocation Needs a strategy Can be more direct if the server stores the key
End user Yes, depending on the flow Generally not its main job
OAuth 2.0 Can be used as an access token Not OAuth 2.0

“Can” is not “does.” A JWT without exp does not expire by format magic. An API key in a system that never deletes rows is not “easy to revoke.” A JWT that is only checked locally is valid until exp. Killing it early means a denylist, a short TTL plus refresh, or rotating keys. An API key that is a row can be disabled on the next request. Opaque access tokens have the same lever.

What kind of consumer do you have?

Classify the caller before you classify the token.

flowchart TD
    api[API]
    api --> user[End user]
    api --> service[Internal service]
    api --> partner[External partner]
    user --> jwtEx["Access token, often JWT"]
    service --> tokenEx["Access token / workload identity"]
    partner --> keyEx[API Key]

Those labels are examples. A user-facing API can use a server-side session cookie and never mint a JWT. A partner can use OAuth. An internal service can use mTLS and skip bearer tokens entirely.

End user. You need identity and authorization: sub, what they may do, and a lifetime short enough that theft has a bound. An access token fits. Whether that token is a JWT or opaque is a format decision. An API key that one human pastes into a SPA is a long-lived bearer secret with no exp unless you invent one. Do not use API keys for user authentication.

Internal service. You need to know which workload is calling, and often that it is allowed to call this audience. Options include OAuth 2.0 Client Credentials, workload identity, service accounts, mTLS, and a service token. Static secrets inside a mesh are a rotation and leak problem. Pick from the trust model you already run. Do not add JWT because the public API uses JWT.

External integration. A partner, a Zapier-style connector, a customer’s backend. You may only need to identify the application, meter it, and cut it off. An API key is a common fit. If the partner must act as one of their users, or you need delegated access, OAuth is the framework built for that. “Partner” is not a synonym for “API key.”

Hybrid architecture

Yes, you can use JWT and API keys together. The combinations are not equally good, and they are not equally cheap.

Different consumers (typical SaaS). Users present an access token — possibly a JWT. Partners present an API key. The same HTTP API accepts both. Each mechanism has a different job: user authorization versus consumer identification and metering. Nothing in RFC 7519 or RFC 6750 forbids it. Nothing requires it either.

A B2B platform with a web app, a mobile app, external integrations, and internal workers does not need the same mechanism for every caller:

  • Web and mobile sign a user in. They need a subject, scopes or equivalent, an audience, and a lifetime. JWT is a reasonable format if the API will validate locally; opaque is reasonable if you already introspect. Do not stretch exp to thirty days to avoid a session design.
  • External partner syncs catalog data at night. There is no end user on the call. An API key (restricted, rotated, stored hashed, never in a URL) matches that job. If later they must act on behalf of one of their users, that is a new grant. Add OAuth then.
  • Internal service issues invoices from a worker. Cloud workload identity or Client Credentials binds the workload to an audience. A long-lived API key in the worker’s env works until someone copies the env. A JWT the worker mints for itself with a shared HS256 secret is a static secret with extra steps.

API Gateway, then the API. The gateway uses a key to know which application is calling and to apply a plan. The backend uses an access token to authorize what this request may do. Do that when the gateway’s job (metering third-party developers, protecting origin, billing) is real. Do not do it because a diagram had two boxes.

Both on the same request. Technically possible: a stolen user token is useless without the partner key, or a leaked key cannot call user-scoped routes without a user token. It also raises complexity and the chance that one of the two checks is misapplied. Do not add two credentials because “more authentication means more security.” OWASP wants you to understand the mechanisms you already have. There must be a concrete threat — two trust boundaries that both have to speak on this call — before the second credential earns its keep.

Authentication still happens before authorization. Mixing keys and tokens does not skip that order.

Security is a lifecycle, not a format

JWT is not automatically safer. API keys are not automatically unsafe. Security is issuance, storage, transmission, validation, rotation, and revocation.

Treat an API key like a password. OWASP’s Secrets Management cheat sheet exists because teams still hardcode them. OWASP REST and Google Cloud both say the key must not appear in the URL — use a header (x-goog-api-key or equivalent). RFC 6750 §2.3 says the same for access tokens. A header is hygiene, not a transformation: X-API-Key: secret over HTTP in a gist is still a leaked bearer credential. Restrict the key. Rotate by minting a new one, moving clients, deleting the old. Disable the row to revoke.

For JWT, RFC 8725 is the BCP: allowlist alg, bind keys to iss, require and check aud when one issuer serves several APIs, do not fetch keys from jku/x5u. A bearer JWT in localStorage is available to XSS; a JWT in a URL is available to logs. Short TTL limits the theft window; it is not a kill switch. The sentence to kill in code review: “It is signed, so nobody can read it.” Signed is not encrypted. OWASP’s JWT cheat sheet is blunt: if you need logout, you need a denylist or a session. Stateless is a deployment convenience, not a security property.

Expiration is not revocation

A JWT may contain exp. If the verifier checks it, the token dies at that instant. Until then, a locally validated JWT remains acceptable even if the user logged out, the password changed, or the admin disabled the account — unless you add another check.

Short-lived access tokens shrink that window. They do not give you immediate revocation. Immediate revocation needs state: a denylist keyed on jti, introspection of an opaque token, a session row, or a token status list. RFC 7519 says jti can be used to prevent replay. It does not implement a denylist for you.

If you need sessions, design sessions. Do not comment // JWT is stateless on a product that has a Logout button. The refresh-token article is the architecture of that button.

An API key’s lifecycle is whatever you built. Google Cloud documents rotate and delete. OWASP REST says revoke on abuse. A key hardcoded in a library you cannot update does not rotate. Opaque access tokens sit closer to keys on this axis: the server owns the row. JWT sits closer to a certificate: valid until expiry unless you add a list.

When an API key is enough

An API key can be a reasonable fit when:

  • you run a public developer API and need to identify callers, meter them, and stop farming (OWASP REST’s original use);
  • you need consumer identity, not user identity;
  • rate limiting and quotas are the main controls;
  • the integration is an application talking as itself;
  • there is no end-user identity that must be represented on the request.

It is not enough, by OWASP REST’s own warning, as the exclusive control on sensitive, critical, or high-value resources. It is not a user login. It is not OAuth. If the partner later needs delegated user access, you outgrew the key for that path; you did not prove that keys “don’t work.”

When you need access tokens

You need something that represents an authorization when an authenticated user is calling; when you need scopes, audience restriction, a credential that expires, identity on the request, or delegation (OAuth’s actual job); or when the API is protected as an OAuth resource.

Access token does not mean JWT. RFC 6749 allows different formats. A JWT access token can be validated locally with the issuer’s keys (RFC 9068 describes that profile). An opaque token is validated by looking it up or introspecting it. Local JWT validation does not talk to the authorization server on every request; it also does not see a revocation until exp or a denylist. Choose the operational trade, not the buzzword.

When JWT is unnecessary

JWT is not a default upgrade. You may not need it when you only need to identify a consumer, only need rate limiting and quotas, do not need to transport claims, a simpler credential already names the caller correctly, or every request already hits a session store so the “stateless” pitch is fiction.

Signing, key rotation, aud checks, and alg allowlists are real work. They pay off when claims must travel. They are ceremony when the API would have been correct with a hashed API key and a row. OWASP’s JWT cheat sheet asks the same question before the algorithms table: consider whether you need JWTs at all.

A decision tree, not a spec

This is a conceptual guide. It is not a normative algorithm.

flowchart TD
    A["What kind of consumer?"] --> B{"End user?"}
    B -->|Yes| C[Access Token]
    B -->|No| D{"Identify an application?"}
    D -->|Yes| E{"Need fine-grained authorization?"}
    D -->|No| F[Define the trust model]
    E -->|No| G["API Key may be adequate"]
    E -->|Yes| H["Consider Access Token / OAuth 2.0"]
    C --> I{"Is JWT appropriate?"}
    I -->|Yes| J["Access Token in JWT format"]
    I -->|No| K[Opaque Access Token]

“Fine-grained authorization” here means the request must carry or resolve a grant (scopes, user, audience), not that you dislike API keys. “Is JWT appropriate?” means: do you need self-contained claims and local validation, and will you operate keys and exp honestly? If you will introspect anyway, opaque is simpler.

A NestJS sketch, after the architecture

NestJS can implement this. It does not invent it. Guards decide whether a route may run. Authentication shows a JWT guard that verifies a bearer token and assigns the payload to the request. Authorization is a later guard that reads that principal. Keep those jobs split.

GET /v1/products
X-API-Key: ...
GET /v1/orders
Authorization: Bearer ...
@Injectable()
export class ApiKeyGuard implements CanActivate {
  constructor(private readonly consumers: ConsumerService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const apiKey = request.headers["x-api-key"];
    if (typeof apiKey !== "string" || apiKey.length === 0) {
      throw new UnauthorizedException();
    }

    const consumer = await this.consumers.findByKeyHash(sha256(apiKey));
    if (!consumer || consumer.revokedAt) {
      throw new UnauthorizedException();
    }

    request.consumer = consumer;
    return true;
  }
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private readonly jwt: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const [scheme, token] = request.headers.authorization?.split(" ") ?? [];
    if (scheme !== "Bearer" || !token) {
      throw new UnauthorizedException();
    }

    try {
      request.user = await this.jwt.verifyAsync(token, {
        secret: process.env.JWT_ACCESS_SECRET,
        algorithms: ["RS256"],
        issuer: process.env.JWT_ISSUER,
        audience: process.env.JWT_AUDIENCE,
      });
    } catch {
      throw new UnauthorizedException();
    }

    return true;
  }
}

Read the API key from a header, not from the query string. Compare against a hash. Do not log the raw key. algorithms is an allowlist. Issuer and audience are application policy, matching RFC 8725 when you use those claims.

JwtAuthGuard authenticates. A RolesGuard or policies guard authorizes. Do not hide ACL checks inside signature verification. A route that accepts either credential is possible if the handler can bind both request.user and request.consumer to a common authorization model. A route that requires both is the “two credentials” case above. Do not glob APP_GUARD for both and call it defense in depth.

Secrets come from the environment, not from the repo. This is a sketch. The architecture is: pick the guard that matches the consumer, then authorize.

Common mistakes

  • “JWT is an authentication system.” RFC 7519 defines a claims format. Login, session, and logout are application or protocol work (OAuth, OIDC, cookies).
  • “JWT and OAuth are the same thing.” OAuth issues access tokens (RFC 6749). JWT is a format those tokens may use (RFC 9068) or may not (opaque strings, RFC 6750).
  • “JWT is always better than an API key.” Claims and local validation are JWT’s job. Consumer metering is a common API-key job. Security follows the lifecycle, not the acronym.
  • “API keys are insecure by nature.” They are bearer secrets. So is a bearer JWT (RFC 6750). OWASP warns against using keys as user authentication and as the only control on high-value resources.
  • “If the JWT is signed, nobody can read it.” Signed is not encrypted. RFC 7519 separates JWS and JWE.
  • “JWT must always be stateless.” OWASP’s JWT cheat sheet treats fully stateless user sessions as a design to question. Logout and revocation need state, a short TTL plus refresh, or a different session model.
  • “An API key does not need rotation.” Google Cloud tells you to rotate. A static bearer secret that never changes is a permanent leak window.
  • “We should use JWT and an API key together to be safer.” Two broken checks are not stronger than one correct one. Add the second credential when two principals must both be bound to the request.
  • “An API must use a single authentication mechanism.” Users and partners are not the same consumer. Uniformity is optional. Clear responsibility per mechanism is not.

A practical decision table

Not a standard. A prompt for the design conversation.

Need API Key Access Token / JWT
Identify an application
Identify a user
Rate limiting
Quotas
Claims JWT ✓
Scopes
End user Generally no
External integration
Service-to-service
OAuth 2.0
Public developer API May complement
SaaS with users and partners

Both columns can be true on the same platform. “Generally no” for end users is OWASP API2, not a taste.

Sources