Skip to content

JWT Token Generator for EntraID Certificate Authentication

Overview

This Python script generates JSON Web Tokens (JWT) for certificate-based authentication with Microsoft EntraID and Graph API. It is an essential component of the EntraID credential lifecycle automation, enabling secure certificate-based authentication and proof-of-possession validation for credential management operations.

The script supports two distinct token types: - Client Assertion JWT: OAuth2 certificate-based authentication tokens for acquiring access tokens from Microsoft identity platform - Proof of Possession (PoP) JWT: Security tokens that prove ownership of a certificate, required by Microsoft Graph API's addKey and removeKey endpoints

High-Level Flow

  1. Environment Variable Validation: Script validates all required environment variables are present
  2. Certificate Loading: Load X.509 certificate and private key from PFX file
  3. Token Type Selection: Determine which token type to generate (client_assertion or pop)
  4. Header Construction: Build JWT headers with certificate thumbprint (x5t) and optionally certificate chain (x5c)
  5. Claims Assembly: Create JWT payload with appropriate claims for the token type
  6. Cryptographic Signing: Sign JWT with RSA-256 using certificate's private key
  7. Token Output: Print signed JWT token to stdout for Ansible consumption

Architecture Diagram

flowchart TD
    Ansible[Ansible Playbook] -->|Set Environment Variables| Script[generate_jwt_token.py]

    Script --> Validate[Validate Environment Vars]
    Validate -->|Missing Vars| Error1[Exit with Error]
    Validate -->|Valid| LoadCert[Load PFX Certificate]

    LoadCert -->|Load Failure| Error2[Exit with Error]
    LoadCert -->|Success| CheckType{Check TOKEN_TYPE}

    CheckType -->|client_assertion| GenClient[Generate Client Assertion JWT]
    CheckType -->|pop| GenPoP[Generate PoP JWT]
    CheckType -->|Invalid| Error3[Exit with Error]

    GenClient --> Headers1[Headers: alg, typ, x5t]
    Headers1 --> Claims1[Claims: aud, iss, sub, exp, jti]
    Claims1 --> Sign1[Sign with RS256]

    GenPoP --> Headers2[Headers: alg, typ, x5t, x5c]
    Headers2 --> Claims2[Claims: aud, iss, sub, iat, exp]
    Claims2 --> Sign2[Sign with RS256]

    Sign1 --> Output[Print JWT to stdout]
    Sign2 --> Output

    Output --> Ansible2[Ansible Captures Token]
    Ansible2 --> GraphAPI[Use Token for Graph API Calls]

Components

1. Certificate Management

PFX File Loading: - Supports cryptography >= 36.0.0 (modern pkcs12 module) - Fallback to pyOpenSSL for older environments - Extracts: - X.509 Certificate (public key + metadata) - RSA Private Key (for signing JWT)

Certificate Processing: - get_x5t_thumbprint(): Calculate SHA-1 fingerprint in base64url format (certificate thumbprint) - get_x5c_chain(): Encode certificate as base64 DER format (certificate chain)

2. Token Types

Client Assertion JWT (OAuth2 Authentication)

Purpose: Authenticate App Registration with certificate to obtain OAuth2 access token

JWT Header:

{
  "alg": "RS256",
  "typ": "JWT",
  "x5t": "<base64url-encoded-sha1-thumbprint>"
}

JWT Claims:

{
  "aud": "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
  "exp": <current_time + 600>,
  "iss": "{client_id}",
  "jti": "<uuid>",
  "nbf": <current_time>,
  "sub": "{client_id}"
}

Required Environment Variables: - TOKEN_TYPE=client_assertion - PFX_PATH: Path to certificate PFX file - PFX_PASSWORD: PFX file password - TENANT_ID: Azure tenant ID - CLIENT_ID: Application (client) ID

Usage in Playbook:

- name: Generate client_assertion JWT
  ansible.builtin.script:
    cmd: generate_jwt_token.py
    executable: python3
  environment:
    TOKEN_TYPE: "client_assertion"
    PFX_PATH: "/tmp/app_certificate.pfx"
    PFX_PASSWORD: "{{ cert_password }}"
    TENANT_ID: "{{ azure_tenant }}"
    CLIENT_ID: "{{ app_id }}"
  register: client_assertion_result

OAuth2 Flow: 1. Generate client_assertion JWT with this script 2. POST to /oauth2/v2.0/token endpoint:

client_id={app_id}
client_assertion={jwt_token}
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
scope=https://graph.microsoft.com/.default
grant_type=client_credentials
3. Receive access token for Graph API calls


Proof of Possession (PoP) JWT

Purpose: Prove ownership of current certificate to authorize addKey/removeKey operations

JWT Header:

{
  "alg": "RS256",
  "typ": "JWT",
  "x5t": "<base64url-encoded-sha1-thumbprint>",
  "x5c": ["<base64-encoded-certificate>"]
}

JWT Claims:

{
  "aud": "00000002-0000-0000-c000-000000000000",
  "iss": "{object_id}",
  "iat": <current_time>,
  "nbf": <current_time>,
  "exp": <current_time + 600>,
  "sub": "{object_id}"
}

Required Environment Variables: - TOKEN_TYPE=pop - PFX_PATH: Path to certificate PFX file - PFX_PASSWORD: PFX file password - TENANT_ID: Azure tenant ID - SUB_ID: Application object ID (subject) - ISS_ID: Application object ID (issuer) - AUD: Audience (optional, defaults to 00000002-0000-0000-c000-000000000000)

Usage in Playbook:

- name: Generate PoP token for addKey API
  ansible.builtin.script:
    cmd: generate_jwt_token.py
    executable: python3
  environment:
    TOKEN_TYPE: "pop"
    PFX_PATH: "/tmp/app_certificate.pfx"
    PFX_PASSWORD: "{{ cert_password }}"
    TENANT_ID: "{{ azure_tenant }}"
    SUB_ID: "{{ app_object_id }}"
    ISS_ID: "{{ app_object_id }}"
    AUD: "00000002-0000-0000-c000-000000000000"
  register: pop_token_result

Graph API Flow: 1. Obtain access token using client_assertion (see above) 2. Generate PoP token with this script (using CURRENT certificate) 3. POST to /applications/{id}/addKey or /applications/{id}/removeKey:

{
  "keyCredential": { ... },
  "proof": "{pop_jwt_token}"
}
4. Microsoft validates PoP token signature matches existing certificate


3. Cryptographic Operations

Signature Algorithm: RSA-256 (RS256) - Industry standard for JWT signing - 2048-bit or 4096-bit RSA keys supported

Token Validity: 600 seconds (10 minutes) - Short-lived tokens minimize security risk - Sufficient for immediate API consumption

Certificate Thumbprint (x5t): - SHA-1 hash of certificate's DER encoding - Encoded in base64url format - Microsoft uses this to identify which certificate signed the JWT

Certificate Chain (x5c): - Full certificate in base64-encoded DER format - Required for PoP tokens so Microsoft can validate certificate details - Optional for client_assertion (x5t sufficient)


Integration with Ansible Playbooks

EntraID Secrets Renewal (entraid_secrets_renewal.yml)

Certificate Renewal Flow: 1. Client Assertion: Authenticate with OLD certificate to get access token

TOKEN_TYPE: "client_assertion"
PFX_PATH: "/tmp/{{ old_pws_document.document.DocumentName }}"
CLIENT_ID: "{{ app_info.applications[0].app_id }}"

  1. Proof of Possession: Prove ownership of OLD certificate

    TOKEN_TYPE: "pop"
    PFX_PATH: "/tmp/{{ old_pws_document.document.DocumentName }}"
    SUB_ID: "{{ app_info.applications[0].object_id }}"
    ISS_ID: "{{ app_info.applications[0].object_id }}"
    

  2. addKey API Call: Add NEW certificate while proving ownership of OLD

    POST /applications/{id}/addKey
    Headers:
      Authorization: Bearer {access_token}
    Body:
      keyCredential: {new_certificate_public_key}
      proof: {pop_token}
    

EntraID Secrets Removal (entraid_secrets_removal.yml)

Certificate Removal Flow: 1. Client Assertion: Authenticate with CURRENT certificate

TOKEN_TYPE: "client_assertion"
PFX_PATH: "/tmp/{{ current_pws_document.document.DocumentName }}"
CLIENT_ID: "{{ app_info.applications[0].app_id }}"

  1. Proof of Possession: Prove ownership of CURRENT certificate

    TOKEN_TYPE: "pop"
    PFX_PATH: "/tmp/{{ current_pws_document.document.DocumentName }}"
    SUB_ID: "{{ app_info.applications[0].object_id }}"
    ISS_ID: "{{ app_info.applications[0].object_id }}"
    

  2. removeKey API Call: Remove expired certificate

    POST /applications/{id}/removeKey
    Headers:
      Authorization: Bearer {access_token}
    Body:
      keyId: {key_id_to_remove}
      proof: {pop_token}
    


Error Handling

Exit Codes

  • 0: Success - JWT token printed to stdout
  • 1: Failure - Error message printed to stderr

Error Scenarios

Missing Environment Variables:

ERROR: Missing required environment variables
Required: PFX_PATH, PFX_PASSWORD, TENANT_ID

Certificate Load Failure:

ERROR: Failed to load certificate: [Errno 2] No such file or directory: '/tmp/missing.pfx'

Invalid Token Type:

ERROR: Invalid TOKEN_TYPE: invalid_type
Must be 'client_assertion' or 'pop'

Missing Token-Specific Variables:

ERROR: CLIENT_ID required for client_assertion
ERROR: SUB_ID and ISS_ID required for PoP token

Ansible Error Handling

Playbooks use register to capture script output:

- name: Generate JWT token
  ansible.builtin.script:
    cmd: generate_jwt_token.py
  environment:
    TOKEN_TYPE: "client_assertion"
    # ... other vars
  register: token_result
  ignore_errors: yes

- name: Check for errors
  ansible.builtin.fail:
    msg: "Token generation failed: {{ token_result.stderr }}"
  when: token_result.rc != 0


Dependencies

Python Packages

cryptography >= 36.0.0  # Preferred for pkcs12 module
PyJWT                    # JWT encoding/decoding
pyOpenSSL               # Fallback for older cryptography versions

Installation:

pip3 install cryptography PyJWT pyOpenSSL

Ansible Requirements

  • Module: ansible.builtin.script (core module)
  • Delegation: delegate_to: localhost (script runs on Ansible controller)
  • Python Interpreter: executable: python3

Security Considerations

Certificate Protection

  • PFX files downloaded to /tmp/ temporarily
  • Ansible no_log: true prevents password exposure in logs
  • Certificates should be cleaned up after use (Ansible always block)

Token Lifetime

  • 600 seconds (10 minutes) validity
  • Short-lived tokens reduce replay attack window
  • Tokens single-use in automation context

Proof of Possession

  • PoP tokens prevent unauthorized credential changes
  • Attacker cannot add/remove certificates without current certificate's private key
  • Self-service rotation requires possession of existing certificate

Private Key Security

  • Private keys never leave PFX file
  • Signing operations performed in-memory
  • Script exits immediately after token generation

Benefits

  1. Certificate-Based Authentication: More secure than client secrets (no password transmission)
  2. Self-Service Rotation: PoP mechanism allows automated certificate renewal without admin intervention
  3. Unified Token Generation: Single script handles both OAuth2 and PoP tokens
  4. Backward Compatibility: Supports both modern and legacy cryptography library versions
  5. Ansible Integration: Clean stdout/stderr separation for easy capture in playbooks
  6. Error Transparency: Clear error messages help troubleshooting
  7. Short-Lived Tokens: 10-minute validity minimizes security exposure
  8. Cryptographic Standards: Uses industry-standard RS256 algorithm and JWT format

Example: Complete Certificate Renewal

# Step 1: Generate client_assertion with OLD certificate
- name: Generate client_assertion JWT
  ansible.builtin.script:
    cmd: generate_jwt_token.py
    executable: python3
  environment:
    TOKEN_TYPE: "client_assertion"
    PFX_PATH: "/tmp/old_cert.pfx"
    PFX_PASSWORD: "SecurePassword123"
    TENANT_ID: "c3c1e6bb-1ebf-4335-ad13-3419266a9781"
    CLIENT_ID: "12345678-1234-1234-1234-123456789abc"
  register: client_assertion_result
  delegate_to: localhost

- name: Set client_assertion fact
  ansible.builtin.set_fact:
    client_assertion: "{{ client_assertion_result.stdout_lines[0] }}"

# Step 2: Get access token
- name: Get access token
  ansible.builtin.uri:
    url: "https://login.microsoftonline.com/{{ tenant_id }}/oauth2/v2.0/token"
    method: POST
    body_format: form-urlencoded
    body:
      client_id: "{{ client_id }}"
      client_assertion: "{{ client_assertion }}"
      client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
      scope: "https://graph.microsoft.com/.default"
      grant_type: "client_credentials"
  register: token_response

- name: Set access token fact
  ansible.builtin.set_fact:
    access_token: "{{ token_response.json.access_token }}"

# Step 3: Generate PoP token with OLD certificate
- name: Generate PoP token
  ansible.builtin.script:
    cmd: generate_jwt_token.py
    executable: python3
  environment:
    TOKEN_TYPE: "pop"
    PFX_PATH: "/tmp/old_cert.pfx"
    PFX_PASSWORD: "SecurePassword123"
    TENANT_ID: "c3c1e6bb-1ebf-4335-ad13-3419266a9781"
    SUB_ID: "{{ object_id }}"
    ISS_ID: "{{ object_id }}"
    AUD: "00000002-0000-0000-c000-000000000000"
  register: pop_token_result
  delegate_to: localhost

- name: Set PoP token fact
  ansible.builtin.set_fact:
    pop_token: "{{ pop_token_result.stdout_lines[0] }}"

# Step 4: Add NEW certificate with PoP proof
- name: Add new certificate
  ansible.builtin.uri:
    url: "https://graph.microsoft.com/v1.0/applications/{{ object_id }}/addKey"
    method: POST
    headers:
      Authorization: "Bearer {{ access_token }}"
      Content-Type: "application/json"
    body_format: json
    body:
      keyCredential:
        type: "AsymmetricX509Cert"
        usage: "Verify"
        key: "{{ new_cert_public_key }}"
        displayName: "App Certificate (2026-01-22)"
      proof: "{{ pop_token }}"
  register: addkey_response

Technical References

Microsoft Documentation: - OAuth 2.0 client credentials flow - Certificate credentials - addKey API - removeKey API

RFC Standards: - RFC 7519: JSON Web Token (JWT) - RFC 7515: JSON Web Signature (JWS) - RFC 7517: JSON Web Key (JWK)