JWT usage guide for integrations
How an external application requests, stores, sends, and verifies JWTs issued by Authfy.
Core idea
Each application (not company) holds one or more access keys — named RSA key pairs created in the support console. Each key has:
- a private key — stored only by the Auth service, encrypted; signs the access and ID tokens issued to clients presenting that key.
- a public key — safe to share with the integrating application; verifies
the JWTs and identifies the application during token/revoke/introspect
calls. Its SHA-256 fingerprint (base64url, unpadded) is the key's
key_idand the JWT headerkid.
An access key can be shared by any number of client deployments — access is still gated per user by each company's subscription to the application.
External apps obtain JWTs only through the OAuth/OIDC Authorization Code +
PKCE flow at POST /api/v1/oauth/token (see
OAuth 2.0 / OIDC). POST /api/v1/auth/login and
POST /api/v1/auth/refresh only return internal UI tokens (HMAC) — never
application JWTs.
Required env vars in an external app
AUTH_API_URL=http://localhost:8081
AUTH_ISSUER=http://localhost:8081
APP_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----"
Browser-exposed Next.js or Vite variants use their framework prefix
(NEXT_PUBLIC_*, VITE_*) but follow the same naming. Rules:
APP_PUBLIC_KEYis the access key's public key, copied from the support console's Access keys page. It is the sole client credential — there is noclient_idorAUTH_APP_ID.APP_PUBLIC_KEYis public material — plain text in env or config is fine.- The access key private key never leaves the Auth service.
- Access and refresh tokens are sensitive — prefer secure
HttpOnlycookies for browser sessions.
Sending the public key
Any call that needs to identify the application uses the header:
X-App-Public-Key: -----BEGIN PUBLIC KEY-----\nMIIB...\n-----END PUBLIC KEY-----
Send escaped \n newlines or the base64 PEM body — both shapes are accepted.
Endpoints that require it: POST /api/v1/oauth/token,
POST /api/v1/oauth/revoke, POST /api/v1/auth/introspect (or the
appPublicKey JSON field), GET /api/v1/external/**, and
GET /api/v1/me/apps.
JWT claims to expect
| Claim | Meaning |
|---|---|
iss | Auth issuer URL. |
sub | User UUID. |
aud / azp / client_id | Application appId — informational only, not a client identifier. |
application_id | Application UUID. |
app_id | Application appId string (informational). |
company_id | Resolved company UUID. |
company_slug | Resolved company slug. |
email, name, system_role | User profile claims. |
public_key_fingerprint | Fingerprint of the signing access key — equals the JWT header kid. |
token_type | access or id. |
iat, exp | Standard time claims. |
Verifying tokens locally — TypeScript / Node
import { importSPKI, jwtVerify } from "jose";
const AUTH_ISSUER = process.env.AUTH_ISSUER!;
const APP_PUBLIC_KEY = process.env.APP_PUBLIC_KEY!.replace(/\\n/g, "\n");
export async function verifyAppJwt(token: string) {
const key = await importSPKI(APP_PUBLIC_KEY, "RS256");
const { payload } = await jwtVerify(token, key, {
issuer: AUTH_ISSUER,
});
return payload;
}
A signature check with your key already proves the token was signed by your
access key — there is no audience check. If you want to fail fast before the
signature check, compare the JWT header kid to your key's fingerprint.
Calling /api/v1/me/apps:
const response = await fetch(`${process.env.AUTH_API_URL}/api/v1/me/apps`, {
headers: {
Authorization: `Bearer ${accessToken}`,
"X-App-Public-Key": APP_PUBLIC_KEY.replace(/\n/g, "\\n"),
},
});
Verifying tokens locally — Spring Boot resource server
auth:
issuer: http://localhost:8081
public-key: |
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqh...
-----END PUBLIC KEY-----
@Configuration
class JwtConfig {
@Bean
JwtDecoder jwtDecoder(@Value("${auth.public-key}") String publicKeyPem,
@Value("${auth.issuer}") String issuer) throws Exception {
var keyBytes = java.util.Base64.getDecoder().decode(publicKeyPem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", ""));
var publicKey = (RSAPublicKey) KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(keyBytes));
var decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();
decoder.setJwtValidator(new JwtIssuerValidator(issuer));
return decoder;
}
}
Verifying tokens locally — PHP
Using firebase/php-jwt:
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
function verify_app_jwt(string $token): array {
$publicKey = str_replace('\\n', "\n", getenv('APP_PUBLIC_KEY'));
$payload = JWT::decode($token, new Key($publicKey, 'RS256'));
if (($payload->iss ?? '') !== getenv('AUTH_ISSUER')) {
throw new RuntimeException('bad issuer');
}
return (array) $payload;
}
JWKS — fetching public keys dynamically
External apps can fetch usable access-key public keys as a JWKS document:
GET /api/v1/oauth/jwks # all usable keys
GET /api/v1/oauth/jwks?kid=<fingerprint> # all usable keys of that key's application
The JWT header kid matches the JWKS kid. Cache the response and refetch on
kid mismatch or after a short TTL. Only usable keys are published:
revoked or expired keys disappear immediately, and tokens they signed stop
verifying. Local verification is preferred for high-volume APIs;
introspection is preferred for revocation-sensitive flows.
Key rotation never changes an existing key's material — a key pair is
generated exactly once at creation. Rotating means creating a replacement
access key in the support console, updating APP_PUBLIC_KEY in your client,
and revoking the old key. Tokens signed by the old key keep verifying until
that key is revoked or expires.
Encrypted cross-app token handoff (optional)
If an integrating app wants the Auth service to encrypt the access token for
transport across an untrusted boundary, use a separate handoff key pair:
the consuming app keeps the handoff private key on its backend, the Auth-side
process encrypts the JWT as JWE with the handoff public key, and the consuming
backend decrypts and then verifies the JWT signature with APP_PUBLIC_KEY.
The handoff pair is unrelated to the access key pair.
Troubleshooting
invalid_token: signature mismatch
- The token was signed by a different access key than the one in
APP_PUBLIC_KEY— for example after a rotation. UpdateAPP_PUBLIC_KEYto the current key, or verify against JWKS bykid. - The JWT was issued for a different application — confirm the header
kidequals your access key's fingerprint.
403 Token key fingerprint does not match the public key
- The
X-App-Public-Keyheader is a different key than the one that signed the JWT, or the signing key is no longer a usable key of the same application (revoked or expired). Use the same access key context for both auth and resource calls.
400 Access key not found
- The configured
APP_PUBLIC_KEYis not the public key of any usable access key — it may be revoked, expired, not yet inside its activity window, or belong to an inactive application. Check it in the support console under Access keys.
400 redirect_uri not in Allowed URLs
The sign-in screen shows this as "The return address is not set up correctly" with the check name Application return URL check.
-
The access key's Allowed URLs list does not include the requested redirect or post-logout URL. Since key material never changes, the fix is to create a replacement access key with the right URLs (and revoke the old one) on the support console's Access keys page.
-
The origin must match exactly — scheme, host, and port. Running the app on
http://localhost:3000whilehttp://localhost:3005is registered fails. -
The entries may be glued together. The list is split on whitespace or commas, so a value like
https://smartfarm-ui.caprover.truecode.africa/nhttp://localhost:3005(an escaped\nthat lost its backslash) is one URL, not two, and neither origin is actually registered. Open the key on the Access keys page and confirm each URL sits on its own line:https://smartfarm-ui.caprover.truecode.africa http://localhost:3005
See the Allowed URLs section of OAuth 2.0 / OIDC for the full matching rules.