AAuthfy Docs

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:

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:

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

ClaimMeaning
issAuth issuer URL.
subUser UUID.
aud / azp / client_idApplication appId.
application_idApplication UUID.
app_idApplication appId string (matches AUTH_APP_ID).
company_idResolved company UUID.
company_slugResolved company slug.
email, name, system_roleUser profile claims.
public_key_fingerprintFingerprint of the signing public key — equals the JWT header kid.
token_typeaccess or id.
iat, expStandard 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

403 Token key fingerprint does not match the public key

400 Application not found for client_id

400 redirect_uri not in Allowed URLs