Advanced Topics

JWT Service Plugin

Enterprise Feature

JWT Service Plugin is available as an add-on as part of an enterprise plan. If you would like to purchase this feature, please contact us at sales@zuplo.com or reach out to your account manager.

Most enterprise features can be used in a trial mode for a limited time. Feel free to use enterprise features for development and testing purposes.

The JWT Service Plugin allows you to create and issue short-lived JSON Web Tokens (JWTs) within your Zuplo API. This plugin is useful for scenarios where you need to issue tokens for authentication, authorization, or other purposes.

The plugin essentially turns your Zuplo API into its own identity provider that can issue JWTs. Your Zuplo API will also serve the standard /.well-known/openid-configuration endpoint and associated JWKS endpoint which can be used by clients to discover the public keys used to verify the JWTs issued by your API.

JWT Token

This service signs JWTs with the EdDSA algorithm, using the Ed25519 signing key that Zuplo provisions for each environment. EdDSA is the only supported signing algorithm. The plugin can't sign tokens with RS256 or any other RSA or ECDSA algorithm, and you can't supply your own key pair.

EdDSA has strong security properties and good performance, but not every JWT library supports it. Make sure the client library that validates your tokens can handle EdDSA.

Use Cases

Some of the common use cases for the JWT service plugin include:

  • Securing downstream APIs by issuing JWTs that can be used to verify that the request is coming from your Zuplo API
  • Securing requests to other Zuplo Gateways (for example, when using the Federated Gateway capability on Zuplo managed dedicated deployments.)
  • Calling third-party APIs that can be configured with federated identity such as AWS, Azure, or Google Cloud.
  • Issuing short lived tokens for client side applications

Setup

To set up the JWT Service Plugin, you need to register it in your zuplo.runtime.ts file.

TypeScriptmodules/zuplo.runtime.ts
import { RuntimeExtensions, JwtServicePlugin } from "@zuplo/runtime"; export function runtimeInit(runtime: RuntimeExtensions) { // Default configuration (no options) const jwtService = new JwtServicePlugin(); runtime.addPlugin(jwtService); }

Configuration Options

The JWT Service Plugin accepts optional configuration to customize its behavior. You can pass a configuration object to the constructor:

TypeScriptmodules/zuplo.runtime.ts
import { RuntimeExtensions, JwtServicePlugin, JwtServicePluginOptions, } from "@zuplo/runtime"; export function runtimeInit(runtime: RuntimeExtensions) { // Example 1: Custom configuration with both options const options: JwtServicePluginOptions = { // Custom base path for the issuer endpoint (default: "/__zuplo/issuer") basePath: "/custom", // Token expiration time (default: "1h") // Can be a number (seconds) or a time span string expiresIn: "5m", // or 300 for seconds }; const jwtService = new JwtServicePlugin(options); runtime.addPlugin(jwtService); }

Available Options

  • basePath (optional): The base path for the JWT issuer endpoint. Default is "/__zuplo/issuer". This affects the issuer URL and OIDC configuration endpoints.

  • algorithm (optional): The signing algorithm for issued JWTs. Leave this option unset or set it to "EdDSA", which is the default. EdDSA is the only algorithm the environment's Ed25519 key can sign with. See Unsupported algorithms for what happens with other values.

  • expiresIn (optional): Sets the default expiration time for JWTs. Default is "1h". Can be either:

    • A number: Direct value in seconds (for example, 300 for 5 minutes)
    • A string: Time span format (for example, "5 minutes", "1 hour", "7 days")

    Valid time units include:

    • Seconds: "sec", "secs", "second", "seconds", "s"
    • Minutes: "minute", "minutes", "min", "mins", "m"
    • Hours: "hour", "hours", "hr", "hrs", "h"
    • Days: "day", "days", "d"
    • Weeks: "week", "weeks", "w"
    • Years: "year", "years", "yr", "yrs", "y" (365.25 days)

    Examples:

    TypeScriptCode
    expiresIn: 300; // 300 seconds expiresIn: "5 minutes"; // 5 minutes expiresIn: "2 hours"; // 2 hours expiresIn: "7 days"; // 7 days expiresIn: "30 mins"; // 30 minutes

    Note: Individual JWT creation can override this default by specifying expiresIn in the signJwt method.

Unsupported algorithms

The plugin only signs with EdDSA. Other algorithm values fail as follows:

  • A symmetric or unknown algorithm, such as "HS256", fails when the plugin is created, so the gateway doesn't start. The error lists the values the option accepts:

    Code
    JwtServicePlugin - Unsupported signing algorithm "HS256". Supported algorithms: EdDSA, RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512.

    Not all of these values work. The environment's key is an Ed25519 key, so only EdDSA can sign tokens.

  • An RSA or ECDSA algorithm, such as "RS256", "PS256", or "ES256", doesn't match the environment's Ed25519 key. The gateway starts, but JwtServicePlugin.signJwt() fails when it imports the key:

    Code
    JwtServicePlugin - Failed to import private key. Ensure it is a valid JWK format and matches the configured algorithm "RS256".

    The OpenID configuration endpoint also advertises the configured algorithm while the JWKS endpoint serves the Ed25519 public key, so clients can't verify tokens against it.

To fix either error, remove the algorithm option or set it to "EdDSA", and validate tokens with EdDSA in your upstream services.

Usage

Once the plugin is registered, you can use it to issue JWTs in custom handlers or policies.

TypeScriptmodules/handlers/jwt-issue.ts
import { ZuploRequest, ZuploContext, JwtServicePlugin } from "@zuplo/runtime"; export async function getJwt(request: ZuploRequest, context: ZuploContext) { const jwt = await JwtServicePlugin.signJwt({ subject: "test-subject", }); return new Response(jwt, { headers: { "content-type": "text/plain" }, }); }

JWT Issuer and OIDC Configuration

When the JWT Service Plugin is enabled, your Zuplo API acts as an identity provider with the following endpoints:

  • Issuer URL: https://{deploymentName}.zuplo.app/__zuplo/issuer (or your custom domain if configured)
  • OIDC Configuration: https://{deploymentName}.zuplo.app/__zuplo/issuer/.well-known/openid-configuration
  • JWKS Endpoint: https://{deploymentName}.zuplo.app/__zuplo/issuer/.well-known/jwks.json

The OIDC configuration endpoint returns a standard OpenID Connect discovery document that includes the JWKS URI for retrieving the public keys used to verify JWTs.

Creating a JWT Authorization Policy

A common pattern is to create a custom policy that automatically adds JWT tokens to outbound requests. This is useful when calling downstream APIs that require authentication.

Here's an example of a custom policy that adds a JWT to the Authorization header of outbound requests:

TypeScriptmodules/policies/jwt-auth-upstream.ts
import { ZuploRequest, ZuploContext, RequestHandlerPlugin, JwtServicePlugin, } from "@zuplo/runtime"; export default async function (request: ZuploRequest, context: ZuploContext) { // Generate a JWT with the configured options const jwt = await JwtServicePlugin.signJwt({ subject: request.user?.sub || "api-gateway", audience: request.url, }); // Add the JWT to the Authorization header of the request const headers = new Headers(request.headers); headers.set("Authorization", `Bearer ${jwt}`); return new ZuploRequest(request, { headers }); }

Validating JWTs in Upstream Services

Upstream services can validate the JWTs issued by your Zuplo API by verifying the signature and claims. The examples below use EdDSA, the algorithm the plugin signs with.

Node.js/Express Example

This example uses the jose library because the popular jsonwebtoken library doesn't support the EdDSA algorithm.

Javascriptvalidate-jwt.mjs
import { createRemoteJWKSet, jwtVerify } from "jose"; // Replace with your actual Zuplo deployment name or custom domain const ISSUER = "https://my-api.zuplo.app/__zuplo/issuer"; // Create a remote JWK Set that fetches and caches the public keys const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`)); // Middleware to validate JWT async function validateJwt(req, res, next) { const token = req.headers.authorization?.replace("Bearer ", ""); if (!token) { return res.status(401).json({ error: "No token provided" }); } try { const { payload } = await jwtVerify(token, JWKS, { issuer: ISSUER, algorithms: ["EdDSA"], }); req.user = payload; next(); } catch (err) { return res .status(401) .json({ error: "Invalid token", details: err.message }); } } // Example usage app.get("/protected", validateJwt, (req, res) => { res.json({ message: "Access granted", user: req.user, }); });

Python/FastAPI Example

EdDSA validation in PyJWT requires the cryptography package. Install PyJWT with the crypto extra: pip install pyjwt[crypto].

The keys in Zuplo's JWKS don't include a kid, so this example loads the key directly from the JWKS document rather than using PyJWKClient, which only matches keys by kid.

validate_jwt.py
from fastapi import FastAPI, Depends, HTTPException, Security from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials import jwt from jwt import PyJWK import requests app = FastAPI() security = HTTPBearer() # Replace with your actual Zuplo deployment name or custom domain ISSUER = "https://my-api.zuplo.app/__zuplo/issuer" JWKS_URL = f"{ISSUER}/.well-known/jwks.json" def get_signing_key() -> PyJWK: # Zuplo publishes a single signing key. Consider caching this # response briefly to avoid fetching the JWKS on every request. jwks = requests.get(JWKS_URL, timeout=10).json() return PyJWK.from_dict(jwks["keys"][0]) async def validate_token(credentials: HTTPAuthorizationCredentials = Security(security)): token = credentials.credentials try: # Verify and decode the token payload = jwt.decode( token, get_signing_key(), algorithms=["EdDSA"], issuer=ISSUER, options={"verify_exp": True} ) return payload except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="Token has expired") except jwt.InvalidTokenError as e: raise HTTPException(status_code=401, detail=f"Invalid token: {str(e)}") @app.get("/protected") async def protected_route(token_data: dict = Depends(validate_token)): return { "message": "Access granted", "user": token_data }

Dynamic OIDC Discovery

For more flexible JWT validation, you can dynamically discover the OIDC configuration based on the issuer claim in the JWT. This example fetches the issuer's OIDC discovery document to find the JWKS endpoint, then verifies the token with the same jose library used in the Node.js example above.

Security Warning

This approach is particularly useful when you have multiple Zuplo APIs with different issuers or when the issuer URL might change (for example, between environments). It's CRITICAL that you validate the issuer claim in the JWT to ensure you are only allowing tokens from trusted issuers.

TypeScriptvalidate-jwt-dynamic.ts
import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose"; import type { JWTPayload } from "jose"; import type { NextFunction, Request, Response } from "express"; // Make the verified JWT payload available as req.user declare global { namespace Express { interface Request { user?: JWTPayload; } } } const ALLOWED_ISSUERS = [ "https://my-api.zuplo.app/__zuplo/issuer", "https://another-api.zuplo.app/__zuplo/issuer", // Add more allowed issuers as needed ]; // Cache the remote JWK Set for each issuer so discovery only runs once. // jose handles JWKS caching and key rotation automatically. const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>(); async function getJwks(issuer: string) { let jwks = jwksCache.get(issuer); if (!jwks) { // Discover the OIDC configuration for the issuer const response = await fetch(`${issuer}/.well-known/openid-configuration`); if (!response.ok) { throw new Error(`OIDC discovery failed with status ${response.status}`); } const metadata = (await response.json()) as { issuer?: string; jwks_uri?: string; }; if (metadata.issuer !== issuer) { throw new Error("Discovery document issuer mismatch"); } if (!metadata.jwks_uri) { throw new Error("Issuer metadata is missing jwks_uri"); } jwks = createRemoteJWKSet(new URL(metadata.jwks_uri)); jwksCache.set(issuer, jwks); } return jwks; } async function validateJwtDynamic(token: string): Promise<JWTPayload> { // Read the issuer claim without verifying the signature yet const { iss: issuer } = decodeJwt(token); if (!issuer) { throw new Error("No issuer claim in token"); } // Validate the issuer against the allow list before fetching anything if (!ALLOWED_ISSUERS.includes(issuer)) { throw new Error(`Issuer ${issuer} isn't allowed`); } // Verify the signature and standard claims const { payload } = await jwtVerify(token, await getJwks(issuer), { issuer, algorithms: ["EdDSA"], }); return payload; } // Express middleware example function validateJwtMiddleware( req: Request, res: Response, next: NextFunction, ) { const token = req.headers.authorization?.replace("Bearer ", ""); if (!token) { res.status(401).json({ error: "No token provided" }); return; } validateJwtDynamic(token) .then((payload) => { req.user = payload; next(); }) .catch((error: Error) => { res .status(401) .json({ error: `JWT validation failed: ${error.message}` }); }); } // Usage app.get("/protected", validateJwtMiddleware, (req, res) => { res.json({ message: "Access granted", user: req.user, }); });

This approach is particularly useful when:

  • You need to validate JWTs from multiple Zuplo APIs with different issuers
  • The issuer URL might change (for example, between environments)
  • You want to leverage automatic OIDC discovery for configuration updates

Important Validation Steps

When validating JWTs from Zuplo:

  1. Verify the signature using the public keys from the JWKS endpoint
  2. Check the issuer matches your Zuplo API's issuer URL
  3. Validate expiration to ensure the token hasn't expired
  4. Verify audience if your tokens include audience claims
  5. Check any custom claims required by your application

The JWT Service Plugin handles key rotation automatically, so always fetch the current public keys from the JWKS endpoint rather than hard coding them.

Last modified on