JWT usage guide for integrations
How an external application requests, stores, sends, and verifies JWTs issued by Authfy.
Core idea
Each application (not company) has an RSA key pair:
- Private key — stored only by the Auth service; signs access and ID tokens issued for that application.
- Public key — safe to share with the integrating application; verifies the JWTs and identifies the application during token/revoke/introspect calls.
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
AUTH_APP_ID=hrm
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:
AUTH_APP_IDmust equal the application'sappIdconfigured in the support console.APP_PUBLIC_KEYis public material — plain text in env or config is fine.- Never put the application private key anywhere outside 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. |
application_id | Application UUID. |
app_id | Application appId string (matches AUTH_APP_ID). |
company_id | Resolved company UUID. |
company_slug | Resolved company slug. |
email, name, system_role | User profile claims. |
public_key_fingerprint | Fingerprint of the signing public 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 AUTH_APP_ID = process.env.AUTH_APP_ID!;
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,
audience: AUTH_APP_ID,
});
return payload;
}
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
app-id: hrm
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,
@Value("${auth.app-id}") String appId) 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 DelegatingOAuth2TokenValidator<>(
new JwtIssuerValidator(issuer),
new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains(appId))));
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');
}
$aud = is_array($payload->aud) ? $payload->aud : [$payload->aud];
if (!in_array(getenv('AUTH_APP_ID'), $aud, true)) {
throw new RuntimeException('bad audience');
}
return (array) $payload;
}
JWKS — refreshing a public key after rotation
External apps can fetch the application's public key dynamically:
GET /api/v1/oauth/jwks?client_id=hrm
The JWT header kid matches the JWKS kid. Cache the response and refetch on
kid mismatch or after a short TTL. This is the recommended approach for
high-traffic services or frequent key rotation. Local verification is
preferred for high-volume APIs; introspection is preferred for
revocation-sensitive flows.
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 application's signing pair.
Troubleshooting
invalid_token: signature mismatch
- The application's signing key was rotated — refresh JWKS or update
APP_PUBLIC_KEY. - The JWT was issued for a different application — confirm
aud/app_idequalsAUTH_APP_ID.
403 Token key fingerprint does not match the public key
- The
X-App-Public-Keyheader is a different application's key than the one that signed the JWT. Use the same application context for both auth and resource calls.
400 Application not found for client_id
- The configured
AUTH_APP_IDis not theappIdof any active application. Check it in the support console under Applications.
400 redirect_uri not in Allowed URLs
- The application's Allowed URLs list does not include the requested redirect or post-logout URL. Add it under Application → Actions → Allowed URLs.