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";
async function createServiceAccessToken(): Promise<string> {
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");
}
return response.jwt;
}import (
"errors"
"github.com/supertokens/supertokens-golang/recipe/jwt"
)
func createServiceAccessToken() (string, error) {
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
}
if response.OK == nil {
return "", errors.New("JWT creation failed")
}
return response.OK.Jwt, nil
}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.jwtfrom 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.jwtPrefer 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:
- Allow only
RS256; reject an unexpected or missingalgorkid. - Verify the signature with the JWKS key selected by
kid. - Reject every library error before reading claims. This includes an invalid signature, expired token, malformed token, unknown key, and issuer or audience mismatch.
- Require
expand reject expired tokens. Do not disable expiry verification or add an unbounded clock tolerance. - Require exact expected values for
iss,aud,source, andtoken_type. - Require an approved
subservice 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.