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
- Environment Variable Validation: Script validates all required environment variables are present
- Certificate Loading: Load X.509 certificate and private key from PFX file
- Token Type Selection: Determine which token type to generate (client_assertion or pop)
- Header Construction: Build JWT headers with certificate thumbprint (x5t) and optionally certificate chain (x5c)
- Claims Assembly: Create JWT payload with appropriate claims for the token type
- Cryptographic Signing: Sign JWT with RSA-256 using certificate's private key
- 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:
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
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:
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 }}"
-
Proof of Possession: Prove ownership of OLD certificate
-
addKey API Call: Add NEW certificate while proving ownership of OLD
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 }}"
-
Proof of Possession: Prove ownership of CURRENT certificate
-
removeKey API Call: Remove expired certificate
Error Handling
Exit Codes
- 0: Success - JWT token printed to stdout
- 1: Failure - Error message printed to stderr
Error Scenarios
Missing Environment Variables:
Certificate Load Failure:
Invalid Token Type:
Missing Token-Specific Variables:
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:
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: trueprevents 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
- Certificate-Based Authentication: More secure than client secrets (no password transmission)
- Self-Service Rotation: PoP mechanism allows automated certificate renewal without admin intervention
- Unified Token Generation: Single script handles both OAuth2 and PoP tokens
- Backward Compatibility: Supports both modern and legacy cryptography library versions
- Ansible Integration: Clean stdout/stderr separation for easy capture in playbooks
- Error Transparency: Clear error messages help troubleshooting
- Short-Lived Tokens: 10-minute validity minimizes security exposure
- 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)