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) holds one or more access keys — named RSA key pairs created in the support console. Each key has:

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:

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 — informational only, not a client identifier.
application_idApplication UUID.
app_idApplication appId string (informational).
company_idResolved company UUID.
company_slugResolved company slug.
email, name, system_roleUser profile claims.
public_key_fingerprintFingerprint of the signing access 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 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

403 Token key fingerprint does not match the public key

400 Access key not found

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.

See the Allowed URLs section of OAuth 2.0 / OIDC for the full matching rules.