omniboost / aws-iam-proof-auth
Service-to-service authentication with the caller's AWS IAM identity: sign outgoing requests and verify incoming ones with AWS-IAM-PROOF headers. Includes Laravel middleware.
Package info
github.com/omniboost/aws-iam-proof-auth
Language:Go
pkg:composer/omniboost/aws-iam-proof-auth
Requires
- php: ^8.2
- aws/aws-sdk-php: ^3.300
- guzzlehttp/guzzle: ^7.8
- guzzlehttp/psr7: ^2.6
- psr/log: ^2.0 || ^3.0
Requires (Dev)
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^10.5 || ^11.0 || ^12.0 || ^13.0
Suggests
- laravel/framework: For the middleware, the Http::withAwsIamProof() macro and the cache-backed nonce store (^12 || ^13)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-22 11:00:32 UTC
README
Service-to-service authentication with the caller's AWS identity. No shared secrets: a service proves who it is with the IAM role it already runs as.
One repository, two implementations of the same protocol:
- a Go module,
github.com/omniboost/aws-iam-proof-auth - a Composer package,
omniboost/aws-iam-proof-auth, with Laravel integration
A header signed by either one verifies on the other. The tests check this
against shared fixtures in testdata/.
How it works
The caller presigns an STS GetCallerIdentity request with its own AWS
credentials and sends the presigned URL in the Authorization header:
Authorization: AWS-IAM-PROOF <base64url(presigned STS URL)>
The receiving service:
- checks the URL is an https STS endpoint (
sts.*.amazonaws.com,sts-fips.*,sts.*.api.aws) callingGetCallerIdentity; - checks
X-Amz-Expiresis at most 120 seconds andX-Amz-Dateis at most 5 minutes old; - claims the
X-Amz-Noncein a shared nonce store, so the header works once; - calls the URL. STS answers with the caller's ARN, for example
arn:aws:sts::123456789012:assumed-role/Developer/alice.
The result is a principal: account ID 123456789012, role Developer. SSO
roles lose their generated parts, so AWSReservedSSO_Developer_0a1b2c3d becomes
Developer. Only assumed roles are accepted. IAM users are rejected.
The nonce is in the query before signing, so the signature covers it. The receiver never sees the caller's credentials, only a URL that STS accepts once within a minute. That is also why the code can be public: everything it does is standard AWS signing and a call to STS.
Permissions are granted per account ID + role. They are plain strings such as
pipelines.view and mean whatever the receiving service decides.
ACL grants
Both languages read the same JSON, from the AWS_IAM_PROOF_ACL environment
variable or from wherever you pass it in:
[
{"account_id": "123456789012", "role": "Developer", "permissions": ["pipelines.view", "pipelines.edit"]},
{"account_id": "210987654321", "role": "ci-deploy", "permissions": ["pipelines.view"]}
]
Parsing is strict, because a typo silently denies access: unknown fields, an account ID that is not 12 digits, an empty role or an empty permission are errors. Entries for the same account + role are merged. The Go Mongo store reads documents of the same shape.
Go
go get github.com/omniboost/aws-iam-proof-auth
Signing outgoing requests
import iamproof "github.com/omniboost/aws-iam-proof-auth" signer, err := iamproof.NewDefaultSigner(ctx) // or iamproof.NewSigner(awsConfig) if err != nil { return err } // Every request gets a fresh header, retries included. client := &http.Client{Transport: &iamproof.Transport{Signer: signer}} // Or by hand: header, err := signer.Header(ctx) req.Header.Set("Authorization", header)
Build the signer once. It keeps the aws.Config, so credentials are cached
instead of reloaded per request.
Verifying incoming requests
import ( iamproof "github.com/omniboost/aws-iam-proof-auth" "github.com/omniboost/aws-iam-proof-auth/acl" "github.com/omniboost/aws-iam-proof-auth/middleware" "github.com/omniboost/aws-iam-proof-auth/valkeynonce" ) verifier := iamproof.NewVerifier(valkeynonce.New(valkeyClient)) store, ok, err := acl.FromEnv() // AWS_IAM_PROOF_ACL if err != nil { return err } if !ok { store = acl.NewMemoryStore() // no grants: every permission check fails } auth := middleware.NewAuth(verifier, store, middleware.WithLogger(logger)) mux.Handle("GET /pipelines", auth.Require("pipelines.view")(listHandler)) mux.Handle("POST /identity", auth.Require()(identityHandler)) // identity only // In a handler: principal, _ := iamproof.PrincipalFromContext(r.Context())
Errors are JSON, {"error": "..."}: 401 for a missing or invalid proof, 403 for
a missing permission, 500 when the ACL lookup fails. Use
middleware.WithErrorWriter to change the format. When the nonce store or STS
fails, the error wraps iamproof.ErrInfrastructure and is logged at error level.
An invalid proof is logged at info.
Packages:
| Package | What |
|---|---|
iamproof |
Signer, Transport, Verifier, Principal, MemoryNonceStore |
acl |
Store, MemoryStore, CachedStore (5s TTL), ParseGrants, FromEnv |
acl/mongoacl |
Store on a Mongo collection (acl_grants), with EnsureIndexes |
valkeynonce |
Nonce store on Valkey/Redis (SET NX EX), shared across instances |
middleware |
net/http middleware: RequireIdentity, RequirePermission, Auth.Require |
MemoryNonceStore only protects a single process. Anything with more than one
instance, Lambda included, needs valkeynonce or another shared store.
Postman
Postman cannot run AWS signing in a pre-request script, so a loopback helper makes the header for it:
go run github.com/omniboost/aws-iam-proof-auth/cmd/authhelper@latest # http://127.0.0.1:7070 go run github.com/omniboost/aws-iam-proof-auth/cmd/authhelper@latest -print # print one header
GET / returns {"authorization": "AWS-IAM-PROOF ..."}. Set it as the
Authorization header in a pre-request script.
PHP / Laravel
composer require omniboost/aws-iam-proof-auth
Requires PHP 8.2+. The Laravel integration supports Laravel 12 and 13 and is auto-discovered.
Signing outgoing requests
// Laravel: a fresh header per request, retries included. Http::withAwsIamProof()->get('https://api.example.com/v1/identity'); // Plain Guzzle: $stack = HandlerStack::create(); $stack->push(GuzzleMiddleware::sign(new Signer())); $client = new Client(['handler' => $stack]); // By hand: $header = (new Signer())->header();
Signer uses the AWS SDK's default credential chain unless you pass a
credential provider. In Laravel, set AWS_IAM_PROOF_ACCESS_KEY_ID and
AWS_IAM_PROOF_SECRET_ACCESS_KEY to sign with static keys, for example in local
development.
Verifying incoming requests
Route::get('/orders', ...)->middleware('aws-iam-proof'); // identity only Route::post('/orders', ...)->middleware('aws-iam-proof:orders.view,orders.edit'); // and every listed permission // In the controller: $principal = $request->awsIamPrincipal(); // Omniboost\AwsIamProof\Principal $principal->accountId; $principal->role;
Responses and status codes match the Go middleware.
Used nonces go into a Laravel cache store, through Cache::add(). That store
must be shared by every instance and atomic: redis, memcached, dynamodb or
database. The file and array drivers do not stop a replay sent to another
worker. Pick the store with AWS_IAM_PROOF_NONCE_STORE.
Grants come from AWS_IAM_PROOF_ACL. To read them from somewhere else, bind
your own Omniboost\AwsIamProof\Acl\AclStore in a service provider.
To change the defaults, publish the config:
php artisan vendor:publish --tag=aws-iam-proof-config
| Env | Default |
|---|---|
AWS_IAM_PROOF_REGION |
AWS_DEFAULT_REGION, then us-east-1 |
AWS_IAM_PROOF_ACCESS_KEY_ID / _SECRET_ACCESS_KEY / _SESSION_TOKEN |
unset: default chain |
AWS_IAM_PROOF_NONCE_STORE |
default cache store |
AWS_IAM_PROOF_ACL |
[]: no grants |
Outside Laravel, use Verifier with any NonceStore and ArrayAclStore
directly. It throws InvalidProofException for client errors and
InfrastructureException for failures on the verifying side.
Development
go test ./... # Go composer install && composer test # PHP (installs into php/vendor)
Composer installs into php/vendor. A vendor/ in the repository root would
put the Go toolchain into vendor mode.
With a live AWS session (e.g. after aws sso login) the Go signer tests sign
with it. Without one they use throwaway keys, since signing never leaves the
process.
testdata/proof/urls.json and testdata/acl/*.json hold the validation cases
both languages run, and testdata/interop/ holds a header signed by each. If
you change a rule, change it in both implementations; the shared tests fail
until you do.
Releasing
Tag vX.Y.Z on master. The same tag serves Go modules and Composer.