A simple library capable of encoding, decoding, and validating signed JWTs.
- PHP 8.x
- OpenSSL PHP extension
composer require nimbly/proof
Create a new Proof
instance with your SignerInterface
instance. The signer is responsible for signing your JWT to prevent tampering of your tokens. The Proof
instance must be provided with your signing preference: HmacSigner
or KeypairSigner
(see Signers section for more information).
$proof = new Proof(
new KeypairSigner(
Proof::ALGO_SHA256,
\openssl_get_publickey($public_key_contents),
\openssl_get_privatekey($private_key_contents)
)
);
Create a new Token
with claims.
$token = new Token([
"iss" => "customer-data-service",
"sub" => $user->id,
"iat" => \time(),
"exp" => \strtotime("+1 hour")
]);
Encode the Token
instance into a JWT string.
$jwt = $proof->encode($token);
Decode the JWT string into a Token
instance.
$token = $proof->decode($jwt);
A Token
instance represents the payload of the JWT, where the meaningful application level data is stored. Things like the subject of the token, the expiration timestamp, etc. This data is called a claim
. For a full list of predefined public claims, see https://www.iana.org/assignments/jwt/jwt.xhtml#claims. You can also use your own custom claims to fit your needs.
When creating a Token
instance, claims may be passed in through the constructor as a simple key => value pair.
$token = new Token([
"iss" => "customer-data-service",
"sub" => $user->id,
"custom_claim_foo" => "bar",
"exp" => \strtotime("+1 hour")
])
Or you can set a claim on a Token
by calling the setClaim
method.
$token->setClaim("nbf", \strtotime("+1 week"));
With a Token
instance, you can encode it into a signed JWT by passing it into the encode
method. You will be returned a signed JWT string.
$jwt = $proof->encode($token);
When encoding a Token
, there are several failure points that will throw an exception:
TokenEncodingException
is thrown if the header or payload could not be properly JSON encoded.SigningException
is thrown if there was a problem signing the JWT with the givenSignerInterface
instance.
When you decode a JWT string it will also verify the signature and check the expiration (exp
) and "not before" (nbf
) claims (if present). If successful, you will receive a Token
instance back loaded with the claims from the payload of the JWT.
$token = $proof->decode($jwt);
You can get a claim on a Token
by calling the getClaim
method.
$subject = $token->getClaim("sub");
You can check whether a claim exists or not by calling the hasClaim
method.
if( $token->hasClaim("sub") ){
// Load User from DB
}
You can get all claims on the Token
by calling the toArray
method.
$claims = $token->toArray();
When decoding a JWT, there are several failure points that will throw an exception:
InvalidTokenException
is thrown if the JWT cannot be decoded due to being malformed or containing invalid JSON.SignatureMismatchException
is thrown if the signature does not match.ExpiredTokenException
is thrown if the token'sexp
claim is expired.TokenNotReadyException
is thrown if the token'snbf
claim is not ready (i.e. the timestamp is still in the future.).
You need a SignerInterface
instance to do the signing and verifying of JWTs.
The HmacSigner
uses a shared secret to sign messages and verify signatures. It is a less secure alternative than using a key pair, as the same secret value used to sign messages must be used in any other system or service that needs to verify that signature.
$hmacSigner = new HmacSigner(
Proof::ALGO_SHA256,
$secretsManager->getSecret("jwt_signing_key")
);
When using a shared secret, remember that it should be considered highly sensitive data and, as such, should not be persisted in a code repository (public or private) or deployed within your application. If an unauthorized 3rd party is able to gain access to your shared secret, they will be able to create their own tokens which could lead to leakage of sensitive data of your users and systems. If you suspect your shared secret has been leaked, generate a new shared secret immediately.
The KeypairSigner
is the preferred signing method as it is more secure than using the HmacSigner
. The key pair signer relies on using a private and public key pair. The private key is used to sign the JWT however the public key can only be used to verify signatures.
The KeypairSigner
relies on a private and/or a public key as an instance of OpenSSLAsymmetricKey
available in PHP since version 4.0 with the openssl
extension/module. You can load the keys using the openssl_get_privatekey
and openssl_get_publickey
PHP functions.
The private key is optional and only required if you need to sign new tokens. The public key is optional and only required if you need to verify signatures of tokens.
For example:
$keypairSigner = new KeypairSigner(
Proof::ALGO_SHA256,
\openssl_get_publickey($secretsManager->getSecret("public_key")),
\openssl_get_privatekey($secretsManager->getSecret("private_key"))
);
If you don't already have one, you can create a key pair using openssl
found on most Linux systems.
openssl genrsa -out private.pem 2048
Using the private key file that was just created (private.pem
), output a public key.
openssl rsa -in private.pem -outform PEM -pubout -out public.pem
You should now have two files called private.pem
and public.pem
. The private.pem
file is your private key and can be used to sign your JWTs. The public.pem
file is your public key and can only be used to validate signatures on your signed JWTs.
Separating private and public keys is especially useful in a distributed or microservice architecture where most services only need to validate a JWT but do not generate their own tokens. For those services you only need the public key.
When creating a key pair, remember that your private key should be considered highly sensitive data and, as such, should not be persisted in a code repository (public or private) or deployed within your application. If an unauthorized 3rd party is able to gain access to your private key, they will be able to create their own tokens which could lead to leakage of sensitive data of your users and systems. If you suspect your private key has been leaked, generate a new key pair immediately.
JWT allows a kid
(short for Key ID) claim/property in the header to include a key ID. This key ID should map to a single unique signing key. Proof
supports multiple signing keys via the keyMap
property in the constructor: you provide a key/value pair array of strings to instances of SignerInterface
.
For JWTs that need to be decoded, Proof
will check the header for a kid
property and, if it exists, will pull the matching SignerInterface
instance from the keyMap
. If no match was found, a SignerNotFoundException
is thrown. If the header does not include a kid
property, the default signer will be used to decode.
For encoding new JWTs, you can pass in an optional kid
parameter into the encode
method. The value of the kid
must exist in the key map and will be used to sign the token. If no match was found, a SignerNotFoundException
is thrown.
If you do not pass in a kid
parameter, the encoding will be done with the default signer.
$proof = new Proof(
signer: $signer,
keyMap: [
"1234" => $signer,
"5678" => $signer2
]
);
$proof->encode($token, "5678");
If you would like to implement your own custom signing solution, a Nimbly\Proof\SignerInterface
is provided. Simply implement this interface with your own solution and pass into the Proof
constructor.
Proof
ships with a PSR-15 middleware you can use in your HTTP applications that will validate a JWT from the ServerRequestInterface
instance. If the JWT is valid, a Nimbly\Proof\Token
attribute will be added to the ServerRequestInterface
instance that contains the Nimbly\Proof\Token
instance. The Token
instance can be used in a further middleware that adds additional context to your request such as a User
instance.
If the JWT is invalid, an exception will be thrown. This exception will need to be handled by your application as you see fit. The possible exceptions thrown are:
InvalidTokenException
is thrown if the JWT cannot be decoded due to being malformed or containing invalid JSON.SignatureMismatchException
is thrown if the signature does not match.ExpiredTokenException
is thrown if the token'sexp
claim is expired.TokenNotReadyException
is thrown if the token'snbf
claim is not ready (i.e. the timestamp is still in the future.).
The middleware defaults to looking for the JWT in the Authorization
HTTP header with a Bearer
scheme. For example:
Authorization: Bearer eyJhbGdvIjoiSFMyNTYiLCJ0eXAiOiJKV1QifQ.eyJtc2ciOiJJIHNlZSB5b3UgbG9va2luZyBpbnRvIHRoaXMgdG9rZW4hIn0.BnWZZhTs3ikgfxI7izf-2XVULbotriCPNJKxf9AYEKU
You can override this behavior in the constructor by supplying the header name (case insensitive) and scheme (case sensitive). If there is no scheme, you can use a null
or empty string value instead.
new Nimbly\Proof\Middleware\ValidateJwtMiddleware(
proof: $proof,
header: "X-Custom-Header",
scheme: null
);
A common practice is to decorate your requests with additional attributes to add more context for your request handlers, such as a User
entity that contains the user making the request. With the use of the Nimbly\Proof\Middleware\ValidateJwtMiddleware
and your own middleware, this becomes a fairly trivial task.
class AuthorizeUserMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$token = $request->getAttribute(Nimbly\Proof\Token::class);
if( empty($token) || $token->hasClaim("sub") === false ){
throw new UnauthorizedHttpException("Bearer", "Please login to continue.");
}
$user = App\Models\User::find($token->getClaim("sub"));
if( empty($user) ){
throw new UnauthorizedHttpException("Bearer", "Please login to continue.");
}
$request = $request->withAttribute(App\Models\User::class, $user);
return $handler->handle($request);
}
}
In this example, each request that requires a user account has had that User
instance attached to the ServerRequestInteface
instance.