Skip to content
Esc
navigateopen⌘Jpreview
Dashboard
On this page

Legacy Flow

Guide for implementing custom JWT authentication between microservices using SuperTokens Core.

Overview

Use the OAuth2 Client Credentials Flow when it is available. It gives each client an identity and uses the standard OAuth2 token and scope model.

This legacy flow is a custom bearer-token scheme for deployments that cannot use client credentials. A service with access to the SuperTokens Core JWT API can mint a token containing arbitrary claims. Consequently, a source or sub claim proves only that a caller with signing access asserted that value; it does not independently prove which service made the request.

The flow is:

Service M1 requests a short-lived JWT from SuperTokens Core

M1 sends the JWT to M2 in the Authorization header

M2 verifies the signature and every required claim before authorizing the request

For stronger isolation, use client credentials or separate trust domains. Deploying separate Cores can create separate signing domains, but it adds operational cost and does not turn a shared Core API key into service identity.

Token policy

For every token:

  • Use a short validity appropriate to the request path. The examples below use five minutes.
  • Use dynamic signing keys. Dynamic keys rotate every 168 hours (one week) by default unless the Core configuration changes access_token_dynamic_signing_key_update_interval.
  • Require an exact issuer (iss), audience (aud), subject/service identity (sub), source, token type, permissions, and expiration (exp) at the receiving service.
  • Grant only the permissions needed by the target API. Do not treat successful signature verification as authorization.
  • Fetch keys from JWKS and support key rotation. Do not embed a public key in the application.

The JWT recipe defaults to a 100-year validity and a static signing key when those arguments are omitted. Those defaults are unsuitable for bearer credentials. Static keys do not rotate. Always pass a short validity and explicitly select the dynamic signing key as shown below.

1. Initialize the JWT recipe

import supertokens from "supertokens-node";
import jwt from "supertokens-node/recipe/jwt";

supertokens.init({
  appInfo: {
    apiDomain: "https://auth.example.com",
    appName: "service-auth",
    websiteDomain: "https://example.com",
  },
  supertokens: {
    connectionURI: "...",
    apiKey: "...",
  },
  recipeList: [jwt.init()],
});
import (
	"github.com/supertokens/supertokens-golang/recipe/jwt"
	"github.com/supertokens/supertokens-golang/supertokens"
)

func main() {
	supertokens.Init(supertokens.TypeInput{
		AppInfo: supertokens.AppInfo{
			AppName:      "service-auth",
			WebsiteDomain: "https://example.com",
			APIDomain:     "https://auth.example.com",
		},
		Supertokens: &supertokens.ConnectionInfo{
			ConnectionURI: "...",
			APIKey:        "...",
		},
		RecipeList: []supertokens.Recipe{
			jwt.Init(nil),
		},
	})
}
from supertokens_python import InputAppInfo, SupertokensConfig, init
from supertokens_python.recipe import jwt

init(
    app_info=InputAppInfo(
        app_name="service-auth",
        api_domain="https://auth.example.com",
        website_domain="https://example.com",
    ),
    supertokens_config=SupertokensConfig(
        connection_uri="...",
        api_key="...",
    ),
    framework="django",
    recipe_list=[jwt.init()],
)

The apiDomain/api_domain/APIDomain value becomes the JWT issuer domain and must be the domain that serves the JWKS endpoint. If this process initializes no other recipe, appName and websiteDomain do not affect this flow.

2. Create a short-lived JWT

Use a fixed schema rather than accepting arbitrary claims from request input. This example identifies M1, limits the token to M2, and grants one permission.

import jwt from "supertokens-node/recipe/jwt";

const response = await jwt.createJWT(
  {
    iss: "https://auth.example.com",
    aud: "service-m2",
    sub: "service-m1",
    source: "microservice",
    token_type: "service_access",
    permissions: ["comments:write"],
  },
  300,
  false,
);

if (response.status !== "OK") {
  throw new Error("JWT creation failed");
}

const accessToken = response.jwt;
validitySeconds := uint64(300)
useStaticSigningKey := false

response, err := jwt.CreateJWT(map[string]interface{}{
	"iss":         "https://auth.example.com",
	"aud":         "service-m2",
	"sub":         "service-m1",
	"source":      "microservice",
	"token_type":  "service_access",
	"permissions": []string{"comments:write"},
}, &validitySeconds, &useStaticSigningKey)
if err != nil {
	return err
}

accessToken := response.OK.Jwt
from supertokens_python.recipe.jwt import asyncio
from supertokens_python.recipe.jwt.interfaces import CreateJwtOkResult

response = await asyncio.create_jwt(
    {
        "iss": "https://auth.example.com",
        "aud": "service-m2",
        "sub": "service-m1",
        "source": "microservice",
        "token_type": "service_access",
        "permissions": ["comments:write"],
    },
    validity_seconds=300,
    use_static_signing_key=False,
)
if not isinstance(response, CreateJwtOkResult):
    raise RuntimeError("JWT creation failed")

access_token = response.jwt
from supertokens_python.recipe.jwt.interfaces import CreateJwtOkResult
from supertokens_python.recipe.jwt.syncio import create_jwt

response = create_jwt(
    {
        "iss": "https://auth.example.com",
        "aud": "service-m2",
        "sub": "service-m1",
        "source": "microservice",
        "token_type": "service_access",
        "permissions": ["comments:write"],
    },
    validity_seconds=300,
    use_static_signing_key=False,
)
if not isinstance(response, CreateJwtOkResult):
    raise RuntimeError("JWT creation failed")

access_token = response.jwt

Prefer the backend SDK. It avoids manually constructing the Core request and keeps the API key out of command-line arguments. If operational tooling must call the released Core API directly, provide the URL and headers through an owner-readable curl config file (0600) populated by your secret tooling. Provide the request body over standard input:

curl --config /run/secrets/supertokens-curl.conf --data-binary @- <<'JSON'
{
  "payload": {
    "iss": "https://auth.example.com",
    "aud": "service-m2",
    "sub": "service-m1",
    "source": "microservice",
    "token_type": "service_access",
    "permissions": ["comments:write"]
  },
  "useStaticSigningKey": false,
  "algorithm": "RS256",
  "jwksDomain": "https://auth.example.com",
  "validity": 300
}
JSON

Configure that file with the /recipe/jwt URL, POST method, rid: jwt, Content-Type: application/json, and api-key header. Do not put the API key in command-line arguments, shell history, environment dumps, or generated logs. Disable shell tracing such as set -x around secret handling, restrict access to the config file, and remove temporary copies immediately after use.

Keep the token in memory only as long as needed. Send it as Authorization: Bearer <token> over TLS. Never log the token or place it in a URL, source file, or long-lived configuration value.

3. Verify and authorize the JWT

The JWKS endpoint is:

<YOUR_API_DOMAIN><API_BASE_PATH>/jwt/jwks.json

With the default API base path, this is https://auth.example.com/auth/jwt/jwks.json. Configure a maintained JWT library to fetch and cache this JWKS, honor its cache behavior, and refetch when it encounters an unknown kid. Dynamic keys rotate every week by default. Static keys may also appear in JWKS, but they do not rotate and must not be selected or hardcoded for this flow.

Do not trust decoded data until the verification library reports success. Verification must:

  1. Allow only RS256; reject an unexpected or missing alg or kid.
  2. Verify the signature with the JWKS key selected by kid.
  3. Reject every library error before reading claims. This includes an invalid signature, expired token, malformed token, unknown key, and issuer or audience mismatch.
  4. Require exp and reject expired tokens. Do not disable expiry verification or add an unbounded clock tolerance.
  5. Require exact expected values for iss, aud, source, and token_type.
  6. Require an approved sub service identity and every permission needed by the endpoint.

For the example above, M2 must require:

Claim Required value
iss https://auth.example.com
aud service-m2
sub An approved calling service, such as service-m1
source microservice
token_type service_access
permissions Includes the endpoint’s required permission
exp Present and in the future

Return 401 Unauthorized when authentication fails. Return 403 Forbidden when the token is valid but its service or permissions do not authorize the operation. Do not reveal signature, key, or claim-validation details to the caller.

Idempotent writes and replay

A valid bearer token can be replayed until it expires. For non-idempotent writes, require a caller-generated Idempotency-Key scoped to the authenticated service and operation. Atomically reserve the key in shared durable storage before the side effect, and return the stored result for an identical retry. Reject reuse with different request data, and retain the record for a bounded period covering the retry window.

If a token must be accepted only once, add a server-generated, unpredictable, unique jti claim when creating it. Before the side effect, atomically insert (iss, sub, jti) into replay storage shared by every service instance; reject the request if it already exists. Keep the entry until at least exp plus the permitted clock skew. Couple replay reservation and the write transaction, or use a transactional outbox, so a crash cannot consume the token without a defined result. An Idempotency-Key or jti is not a substitute for signature, claim, identity, and permission verification.

APIs that accept frontend sessions and service tokens

Prefer separate endpoints or an explicit authentication policy for frontend sessions and service tokens. If one endpoint must accept both, verify each credential only with its intended verifier and apply a separate authorization policy. Never fall back to trusting decoded JWT claims after either verifier returns an error. A malformed, expired, or invalid token must not be downgraded into another authentication path.

Use the backend SDK’s getSession function for frontend session verification. Use the bounded JWKS procedure above for legacy service tokens. Accept the request only after one verifier succeeds and the corresponding identity and permission checks pass.

Compromise response

  • Core API key compromised: revoke and replace it, stop token issuance while investigating, and wait at least the maximum token lifetime before considering previously minted tokens expired. Review issuance and service logs.
  • Dynamic signing key compromised: rotate the signing material, prevent further issuance, and reject affected keys. Network restrictions can reduce exposure but do not make forged tokens safe.
  • Bearer token compromised: revoke or disable the caller where possible and let the short expiry bound exposure. If immediate revocation is required, use an introspected or stateful design rather than this self-contained legacy flow.

API reference

API schema and response details