akamai-open/edgegrid-auth
PHP implementation of Akamai EdgeGrid authentication. It signs requests to Akamai's REST APIs by generating the required Authorization header with timestamp, nonce, and content hash, so you can securely call Fast Purge, Property Manager, and other Akamai OPEN APIs from your PHP applications.
Install
composer require akamai-open/edgegrid-auth
The package is signing-only: it produces an Authorization: EG1-HMAC-SHA256 ... header and nothing else. It ships no HTTP client, no retry logic, and no endpoint models — you bring Guzzle, Laravel's Http facade, or plain cURL.
Get credentials first. In Akamai Control Center go to Identity & Access Management → API clients, create a client, and copy the four values (they are shown once):
host — e.g. akab-xxxxxxxxxxxx-xxxxxxxxxxxx.luna.akamaiapis.net (no scheme, no trailing slash)client_tokenaccess_tokenclient_secretGive the client the API service grants for whatever you're calling (PAPI, Fast Purge, Edge Diagnostics, etc.) — signing will succeed and you'll still get 403 on the API layer if the grant is missing.
First use case — sign one request and fire it
use Akamai\Open\EdgeGrid\Auth\Authentication;
$auth = (new Authentication())
->setHost('akab-xxxxxxxxxxxx-xxxxxxxxxxxx.luna.akamaiapis.net')
->setClientToken(config('services.akamai.client_token'))
->setAccessToken(config('services.akamai.access_token'))
->setClientSecret(config('services.akamai.client_secret'));
$path = '/papi/v1/contracts';
$query = '';
$auth->setHttpMethod('GET')
->setPath($path)
->setQuery($query)
->setHeaders(['Host' => $auth->getHost()]);
$header = $auth->createAuthHeader(); // "EG1-HMAC-SHA256 client_token=...;access_token=...;..."
$response = Http::withHeaders(['Authorization' => $header])
->get('https://' . $auth->getHost() . $path . ($query ? '?' . $query : ''));
Where to look first
src/Auth/Authentication.php in the monorepo — it is ~400 lines and is the entire package. Read createAuthHeader(), makeBaseString(), and canonicalizeHeaders() before debugging anything.Never scatter new Authentication() through controllers. Bind it once:
// config/services.php
'akamai' => [
'host' => env('AKAMAI_HOST'),
'client_token' => env('AKAMAI_CLIENT_TOKEN'),
'access_token' => env('AKAMAI_ACCESS_TOKEN'),
'client_secret' => env('AKAMAI_CLIENT_SECRET'),
],
// app/Services/Akamai/AkamaiSigner.php
namespace App\Services\Akamai;
use Akamai\Open\EdgeGrid\Auth\Authentication;
class AkamaiSigner
{
public function sign(string $method, string $path, string $query = '', array $headers = []): string
{
$auth = (new Authentication())
->setHost(config('services.akamai.host'))
->setClientToken(config('services.akamai.client_token'))
->setAccessToken(config('services.akamai.access_token'))
->setClientSecret(config('services.akamai.client_secret'));
$auth->setHttpMethod(strtoupper($method))
->setPath($path)
->setQuery($query)
->setHeaders($headers ?: ['Host' => config('services.akamai.host')]);
return $auth->createAuthHeader();
}
public function url(string $path, array $query = []): string
{
return 'https://' . config('services.akamai.host') . $path
. ($query ? '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986) : '');
}
}
Build a fresh Authentication per request rather than a singleton — see the nonce gotcha below. Object construction is cheap; a replayed nonce is not.
If you're using a dedicated Guzzle client for Akamai, sign as a middleware so callers never see it:
use Akamai\Open\EdgeGrid\Auth\Authentication;
use Psr\Http\Message\RequestInterface;
function akamaiSigner(callable $makeAuth): callable
{
return fn (callable $handler) => function (RequestInterface $request, array $options) use ($handler, $makeAuth) {
/** @var Authentication $auth */
$auth = $makeAuth();
$auth->setHttpMethod($request->getMethod())
->setPath($request->getUri()->getPath())
->setQuery($request->getUri()->getQuery())
->setHeaders(['Host' => $request->getUri()->getHost()]);
if ($request->getBody()->getSize() > 0) {
$auth->setBody((string) $request->getBody());
}
return $handler(
$request->withHeader('Authorization', $auth->createAuthHeader()),
$options
);
};
}
The middleware runs after the URI and body are final, which is exactly the ordering constraint the scheme imposes.
Akamai client wrapperMost teams end up with a AkamaiClient that exposes the handful of endpoints they actually use, with signing folded into a private send():
public function contracts(): array
{
return $this->send('GET', '/papi/v1/contracts')['contracts']['items'];
}
public function invalidate(array $objects, string $network = 'staging'): array
{
$body = ['objects' => $objects];
return $this->send('POST', "/ccu/v3/invalidate/url/$network", '', $body);
}
private function send(string $method, string $path, string $query = '', ?array $json = null): array
{
$headers = ['Host' => config('services.akamai.host')];
$auth = $this->auth->sign($method, $path, $query, $headers);
$request = Http::withHeaders([
'Authorization' => $auth,
'Accept' => 'application/json',
]);
if ($json !== null) {
$request = $request->asJson()->withBody(json_encode($json), 'application/json');
}
return $request->send($method, $this->signer->url($path, $query ? [$query] : []))
->throw()
->json();
}
Note the asymmetry: Accept is sent but not signed. That is fine and is the recommended default — sign nothing but Host unless you have a reason.
Signing is stateless and CPU-trivial (a SHA-256 and an HMAC). It is safe inside queue workers and Artisan commands. What is not safe is building one Authentication object in a service provider and reusing it across thousands of jobs — the timestamp and nonce go stale. Construct per request.
This is a signing library, not a client. There is no GET helper, no retry, no rate-limit handling. Akamai's APIs return 429 with a Retry-After header under load; you have to implement backoff yourself. If you want the batteries-included path there is a companion Guzzle-based client package in the same monorepo — pull it in instead of hand-rolling unless you specifically need non-Guzzle HTTP.
The class lives under an Auth sub-namespace. If you're porting code from the old monolithic Akamai\Open\EdgeGrid\Client era, Akamai\Open\EdgeGrid\Authentication became Akamai\Open\EdgeGrid\Auth\Authentication in the split package. Copy-pasted snippets from old blog posts will fail with a class-not-found that looks like a broken install.
Sign the URL you actually send. /papi/v1/contracts and /papi/v1/contracts/ are different strings and different signatures. So are ?groupId=1&contractId=2 and ?contractId=2&groupId=1. If any layer in front of you (a proxy, a base-URI helper, Laravel's http_build_query default of PHP_QUERY_RFC1738) rewrites the query, the signature breaks. Normalize query ordering once, in one place, and use RFC3986 encoding.
Do not sign headers you don't send. The canonical header block lists exactly the headers you pass to setHeaders(). Ask for Host only and you'll rarely break. Add Content-Type and forget to set it on a GET and every request 403s with a signature mismatch that looks like a credential problem.
Host must be the Akamai hostname, not an IP or a proxy alias. If you terminate TLS somewhere that rewrites Host, signing must happen on the far side or not at all.
Timestamps expire in ~30 seconds. Queue jobs that sign a request and then wait (rate limiter, sleep, chunked batch) will 403 with Timestamp outside the authentication window. Sign immediately before the send, never at the top of the method.
Nonces are single-use and replay-protected. Reusing an Authentication instance across loop iterations without regenerating the nonce will start failing after the first request. Fastest fix: new Authentication() inside the loop. If you must reuse, call setNonce(null) (or a fresh random value) and setTimestamp(null) before each createAuthHeader().
Bodies over 2048 bytes are hashed partially. EdgeGrid's content hash covers only the first max-body-size bytes (2048 by default). If you set the body on one side and let the server hash a differently-truncated body — usually because a middleware re-encoded JSON — you get a signature mismatch on POST/PUT only, which is a maddening symptom. Set the body once, after all encoding, and don't touch it again.
Debugging a 403. The response body from Akamai includes the signature it computed versus the one it received. Diff them, then walk the canonical request by hand: method, scheme, host, path, sorted query, then the canonical headers, then the signed-header names line, then the body hash. In practice 90% of failures are (a) query ordering, (b) a signed header you didn't send, or (c) a trailing slash.
Deterministic tests. Pass a fixed nonce and timestamp to get a stable header, then assert against a recorded Authorization string. Build the fixture from a real request you know succeeded, not from your own output — a signing library tested against itself proves nothing.
Log the unsigned request, never the secret. client_secret goes into the HMAC and never on the wire, but it's easy to leak it into an exception dump of your config array. Use config()->set() scoping or a #[SensitiveParameter]-style guard when logging near the signer.
The split repo is a mirror. Issues, PRs, and the changelog live on the monorepo (AkamaiOPEN-edgegrid-php). File bugs there. With ~14 stars and no dependents the package gets little independent scrutiny, so pin a version in composer.json and read the diff on upgrade — the surface is small enough to audit in a sitting.
Extension points are minimal, and that's the point. The public API is setters plus createAuthHeader(). There is no middleware chain, no PSR-18 adapter, no event hook. If you need correlation IDs, telemetry, or retries, wrap it — don't fork it.
How can I help you explore Laravel packages today?