php.netjwtcryptographyecdsa

ECDSA-signed JWT validation failures between .NET and PHP


Why does a ECDSA-signed JWT in .NET fail validation in PHP when using OpenSSL, even with matching keys and algorithms?

My example .NET code to generate JWT:

// ECDSA-signed JWT

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;

var privateKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); // ES256
var securityKey = new ECDsaSecurityKey(privateKey) { KeyId = "1" };
var credentials = new SigningCredentials(securityKey, "ES256");
var token = new JwtSecurityToken(
    issuer: "https://example.com",
    audience: "https://example.com",
    claims: new[] { new Claim("test", "value") },
    expires: DateTime.UtcNow.AddHours(1),
    signingCredentials: credentials
);
var jwt = new JwtSecurityTokenHandler().WriteToken(token);
Console.WriteLine("JWT (will fail in PHP): " + jwt);
Console.Read();

My example PHP code to consume JWT:

<?php

require 'vendor/autoload.php';

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

$jwt = '<JWT STRING HERE>'; // From .NET
$publicKey = openssl_pkey_get_public('path/to/public_key.pem'); // PEM-formatted public key.
try
{
   $decoded = JWT::decode($jwt, new Key($publicKey, 'ES256'));
   print_r($decoded);
}
catch (Exception $e)
{
   echo 'Validation error: ' . $e->getMessage();
}

PHP output:

Validation error: OpenSSL error: error:1C800064:Provider routines::bad signature

My instructions to create EXTRA ECDSA PEM formatted public-key for JWT:

# Generate private-key.
openssl ecparam -name prime256v1 -genkey -noout -out ec-private-key.pem

# Extract public-key from private-key.
openssl ec -in ec-private-key.pem -pubout -out ec-public-key.pem

Output files:

Any idea why this happens?


Solution

  • I would generate the keypair with OpenSSL using your commands and then load them like this:

    var privateKey = ECDsa.Create();
    privateKey.ImportFromPem(File.ReadAllText("private-key.pem"));
    

    Then, to prove that the .NET code is fine, past both the JWT and the public key into the jwt.io utility to prove that the JWT is OK.

    enter image description here

    This was my example JWT and public key so maybe try using these values:

    eyJhbGciOiJFUzI1NiIsImtpZCI6IjEiLCJ0eXAiOiJKV1QifQ.eyJ0ZXN0IjoidmFsdWUiLCJleHAiOjE3NDY3MjgzMjQsImlzcyI6Imh0dHBzOi8vZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2V4YW1wbGUuY29tIn0.hTnl1kHaTQ42lr67pkHLXjtcKrYotmveAc3WBAFoXSq-W2TzZA8s13T9cLNxo5GJzvguNxI1Yfto-EOAXvhwqg
    
    -----BEGIN PUBLIC KEY-----
    MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvXGdGX5f3zXCzOUNL5uGHLSOi1NA
    E3VL4vAOBTnAX051u553WGcU33qrrLsoqDYquaokx+zwllBr9sKIJiOQ5w==
    -----END PUBLIC KEY-----
    

    On the PHP side of things, I created a file called decode.php with this content. I had to change the code that loaded the public key, though I encountered a different error to you.

    ```php
    <?php
    
    require 'vendor/autoload.php';
    
    use Firebase\JWT\JWT;
    use Firebase\JWT\Key;
    
    $jwt = 'eyJhbGciOiJ ...';
    
    $publicKey = openssl_pkey_get_public(file_get_contents('./public-key.pem'));
    try
    {
       $decoded = JWT::decode($jwt, new Key($publicKey, 'ES256'));
       print_r($decoded);
    }
    catch (Exception $e)
    {
       echo 'Validation error: ' . $e->getMessage();
    }
    

    Decoding then succeeded with this output:

    stdClass Object
    (
        [test] => value
        [exp] => 1746728324
        [iss] => https://example.com
        [aud] => https://example.com
    )
    

    Perhaps it is something environmental. I actually ran from a Docker container built like this. I got a shell to the container and ran php ./decode.php.

    FROM php:8.1-cli
    
    RUN apt-get update && apt-get install -y \
        zlib1g-dev \
        libzip-dev \
        unzip
    
    RUN docker-php-ext-install zip
    
    COPY --from=composer /usr/bin/composer /usr/bin/composer
    RUN composer self-update
    
    WORKDIR              /usr/myapp
    COPY decode.php      /usr/myapp
    COPY public-key.pem  /usr/myapp
    RUN composer require firebase/php-jwt
    
    CMD ["/bin/sleep", "infinity"]
    

    So ES256 should work in both tech stacks since that algorithm is pretty well supported these days. Follow an elimination approach and use standard tools to verify correctness, so that you can narrow down your issue.