Search by

sugarcommerce / signed-tokens

arraypress

HMAC-signed, expiring, namespaced tokens for URLs. Magic sign-in links, password resets, download URLs and unsubscribe links with no database row. Zero dependencies, supports secret rotation.

Package info

github.com/SugarCommerce/signed-tokens

Homepage

Issues

pkg:composer/sugarcommerce/signed-tokens

Statistics

Installs: 24

Dependents: 3

Suggesters: 0

Stars: 0

1.2.0 2026-10-22 09:11 UTC

This package is auto-updated.

Last update: 2026-10-22 15:44:44 UTC


README

HMAC-signed, expiring, namespaced tokens for URLs. Magic sign-in links, password resets, download URLs, unsubscribe links — carrying their own claims and expiry, with no database row.

Zero dependencies. Not JWT: no algorithm negotiation, no alg: none, no header parsing, and nothing an attacker can influence about how the token is verified.

Features

  • 🔗 URL-safe — unpadded base64url; survives emails, redirects and copy-paste untouched.
  • 🏷️ Mandatory namespaces — a password-reset token can't be replayed as a magic link, even with identical claims.
  • 🔄 Secret rotation — retire a secret without invalidating links already in inboxes.
  • ⏰ Expiry built in — signed, so it can't be extended.
  • 💬 Actionable failures — distinguish "expired" from "invalid" for user-facing messaging, without leaking to attackers.
  • 🛡️ Constant-time — hash_equals, and every known secret checked without early exit.
  • ✍️ Detached mode — sign parameters that travel separately, for existing URL shapes.

Requirements

PHP 8.5+

Installation

composer require sugarcommerce/signed-tokens

Usage

Magic sign-in link

use SugarCommerce\SignedTokens\TokenSigner;

$signer = new TokenSigner( getenv( 'TOKEN_SECRET' ) );

$token = $signer->sign( 'magic-link', [ 'user' => 42 ], 900 );   // 15 minutes
$url   = 'https://example.com/login/' . $token;

Verifying:

$token = $signer->verify( 'magic-link', $_GET['token'] ?? '' );

if ( null === $token ) {
    // invalid, tampered, or expired
}

$user_id = $token->int( 'user' );

Telling the user what went wrong

verify() collapses every failure to null. When the difference matters — an expired magic link deserves "request a new one", not "invalid link" — use inspect():

use SugarCommerce\SignedTokens\{Token, Reason};

$result = $signer->inspect( 'magic-link', $input );

if ( $result instanceof Token ) {
    log_in( $result->int( 'user' ) );
} else {
    echo $result->public_message();
}

The signature is always checked before the expiry, so a forged token comes back as InvalidSignature and never Expired. Nothing in the response tells an attacker they got the structure right.

Namespaces

The namespace is mandatory and is not stored in the token — it's mixed into the signature. A token signed for one purpose can't be verified as another, even with identical claims, and an attacker can't see or influence it:

$reset = $signer->sign( 'password-reset', [ 'user' => 42 ], 900 );

$signer->verify( 'magic-link', $reset );   // null

Secret rotation

Signing always uses the current secret; verification accepts retired ones too. Links already sitting in inboxes keep working, which is what makes rotating practical at all:

$signer = new TokenSigner(
    secret:   getenv( 'TOKEN_SECRET' ),
    previous: [ getenv( 'TOKEN_SECRET_OLD' ) ],
);

Drop the old secret once its longest TTL has passed. Retired secrets are held to the same 32-byte minimum as the current one (an unset getenv() is skipped), since a forger only needs to crack one of them.

Single use

Tokens are stateless, so they are not revocable and not single-use. That's the trade for needing no storage — anyone holding the token can use it repeatedly until it expires.

Where that matters (password resets especially), put a nonce in the payload and record it as spent:

$token = $signer->sign( 'reset', [
    'user' => 42,
    'n'    => bin2hex( random_bytes( 8 ) ),
], 900 );

// On use:
if ( $store->already_seen( $verified->get( 'n' ) ) ) {
    // reject
}

Keep expiries short regardless. Fifteen minutes for sign-in, an hour for downloads.

Detached signatures

For URLs that already carry their parameters and just need authenticating:

$signature = $signer->sign_detached( 'download', [
    'customer' => 1,
    'product'  => 2,
    'file'     => 0,
], $expires_at );

// /download?customer=1&product=2&file=0&exp=…&sig=…

$ok = $signer->verify_detached( 'download', $params, $expires_at, $_GET['sig'] );

Prefer sign() for anything new — this exists so existing URL shapes can be preserved.

Payload rules

Claims must be flat scalars (string, int, bool). Nested arrays, objects, floats and nulls are rejected at signing time rather than silently mangled. Tokens go in URLs — keep them small.

Claim order doesn't affect the token; keys are sorted before signing.

Secrets

At least 32 bytes, enforced:

php -r "echo bin2hex(random_bytes(32));"

Why not JWT?

JWT carries its own algorithm in an attacker-supplied header, which has produced a long run of alg: none and RS256/HS256 confusion vulnerabilities. Here the algorithm is fixed, the namespace is chosen by the verifying code rather than the token, and there's no header to parse. Less flexible, and that's the point.

If you need interop with something that expects JWT, use JWT. If you're signing your own links, this is a smaller target.

Testing

composer install
composer test

95 tests, covering tamper detection, cross-namespace replay, expiry boundaries, rotation, and failure-precedence ordering.

License

GPL-2.0-or-later