Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Reallysimplejwt Laravel Package

rbdwllr/reallysimplejwt

A lightweight PHP library for creating and verifying JSON Web Tokens through a clean, secure API. Really Simple JWT handles signing, validation, and expiration with minimal dependencies, making token-based authentication and stateless APIs straightforward to build.

View on GitHub
Deep Wiki
Context7

Getting Started

rbdwllr/reallysimplejwt is a dependency-free HMAC JWT library — no Laravel service provider, no config file, no facade. You get one static class (ReallySimpleJWT\Token) and an exception namespace. That's the whole surface area, which means you'll wire it into Laravel yourself in about 20 minutes.

Install and configure

composer require rbdwllr/reallysimplejwt

Add the secret to .env (never to a committed file) and expose it through a config file so config:cache doesn't break it:

// config/services.php
'jwt' => [
    'secret' => env('JWT_SECRET'),        // min 12 chars, high entropy
    'issuer' => env('JWT_ISSUER', 'https://api.example.com'),
    'ttl'    => (int) env('JWT_TTL', 3600),
],

Generate the secret with php artisan tinker → Str::random(64) (or openssl rand -hex 32). The library throws a SecretException if the secret is too short — a 12-character minimum is enforced, so don't reach for a placeholder string.

Where to look first

  1. ReallySimpleJWT\Token — the entire public API: create, customPayload, validate, validateExpiration, validateSignature, getPayload, getHeader.
  2. README.md examples — they're accurate and short.
  3. ReallySimpleJWT\Exception\* — the exception types you'll be catching.

First use case: issue on login, verify in middleware

use ReallySimpleJWT\Token;

// In your login controller, after credentials check
$token = Token::create(
    (string) $user->id,
    config('services.jwt.secret'),
    time() + config('services.jwt.ttl'),   // absolute timestamp, NOT a duration
    config('services.jwt.issuer')
);

return response()->json(['token' => $token, 'expires_in' => config('services.jwt.ttl')]);
// app/Http/Middleware/AuthenticateJwt.php
public function handle(Request $request, Closure $next)
{
    $token  = $request->bearerToken();
    $secret = config('services.jwt.secret');

    try {
        if (!$token || !Token::validate($token, $secret) || !Token::validateExpiration($token)) {
            return response()->json(['message' => 'Unauthenticated.'], 401);
        }
        $request->attributes->set('jwt', Token::getPayload($token, $secret));
    } catch (\ReallySimpleJWT\Exception\ReallySimpleJWTException $e) {
        return response()->json(['message' => 'Unauthenticated.'], 401);
    }

    return $next($request);
}

That's the happy path. The rest of this document is about the parts that bite.

Implementation Patterns

Wrap the static API in one service

Token is entirely static, which means it's untestable by mocking and painful to swap later. Bind a thin service and inject it everywhere:

namespace App\Services;

use ReallySimpleJWT\Token;

class JwtService
{
    public function __construct(
        private string $secret,
        private string $issuer,
        private int $ttl,
    ) {}

    public function issue(array $claims): string
    {
        return Token::customPayload([
            'user_id' => $claims['user_id'],
            'iss'     => $this->issuer,
            'exp'     => time() + $this->ttl,
            ...$claims,
        ], $this->secret);
    }

    public function isValid(string $token): bool
    {
        try {
            return Token::validate($token, $this->secret)
                && Token::validateExpiration($token);
        } catch (\Throwable) {
            return false;
        }
    }

    public function payload(string $token): array
    {
        return Token::getPayload($token, $this->secret); // verifies signature first
    }
}
// AppServiceProvider::register()
$this->app->singleton(JwtService::class, fn () => new JwtService(
    config('services.jwt.secret'),
    config('services.jwt.issuer'),
    config('services.jwt.ttl'),
));

Now you can bind a fake in tests, and if you ever move to firebase/php-jwt or lcobucci/jwt, one file changes.

Auth guard integration

If you want auth()->user() to work, implement a Guard rather than stuffing claims into $request->attributes. Resolve the user from the user_id claim inside Guard::user(). Keep the middleware for the cheap 401 path, and let the guard handle hydration — that mirrors how TokenGuard/Sanctum behave and keeps your controllers idiomatic.

Custom claims for roles/tenancy

customPayload() is the right call when you need more than a user id — role, tenant id, scopes. Token::getPayload() returns the full array, so authorization checks become in_array('admin', $claims['roles']). Keep the payload small: every claim is copied on every request and the token goes in a header on every call.

Revocation without built-in revocation

There is no blacklist. Two patterns that work:

  • jti + cache: put a UUID in the payload, store jti => user_id in Redis with the TTL, and delete on logout. Verification does one cache lookup in addition to the signature check.
  • token_version claim: store a monotonically increasing counter on the user row, embed it in the token, reject when it doesn't match. This revokes all sessions for a user and is cheaper than a per-token store.

Short TTLs (15–60 min) plus a refresh endpoint are usually simpler than either.

Verifying tokens from another service

Token::validateSignature($token, $secret) lets you verify one app's token in another without re-checking expiry — useful for service-to-service calls where the caller's clock is the authority. Because it's HMAC-only, both sides must share the same secret; you can't use this to validate JWTs from Auth0/Google/Okta.

Gotchas and Tips

exp is a Unix timestamp, not a TTL. Passing 3600 creates a token that expired in 1970 and validateExpiration() will reject it immediately. Every "it works in the test but not in prod" bug with this library starts here.

validate() does not check expiry. It checks structure and signature only. You must call validateExpiration() separately. Forgetting this is the single most dangerous mistake — expired tokens sail through. Some methods also throw on malformed input rather than returning false, so always wrap in try/catch (or catch Throwable) in middleware.

The issuer is not validated for you. validate() does not compare iss against an expected value. If two apps share a secret, tokens from one are accepted by the other. If multi-issuer matters, fetch the payload and compare iss yourself.

Secret rules are strict and silent-ish. Too-short or empty secrets throw SecretException — usually at request time, not boot time, so a misconfigured .env in production shows up as 500s on login. Add a sanity check in a service provider or a deployment smoke test.

env() outside config files returns null after config:cache. Standard Laravel trap, but especially painful here: you get a SecretException that only reproduces with cached config. Always route through config().

Payloads are readable, not encrypted. Base64url is encoding, not protection. Never put emails, PII, or internal flags in a claim you wouldn't put in a URL. Anyone can decode the payload; only the signature is protected.

Testing time is awkward. validateExpiration() uses time() directly, so Carbon::setTestNow() won't shift it. Test expiry by creating a token with time() - 60 as exp and asserting rejection — don't try to time-travel.

No RS256/ES256, no JWKS, no key rotation. HMAC only (HS256/384/512 depending on version — check what your pinned release supports). If you need asymmetric keys, public-key verification by third parties, or rotating keys without redeploying all consumers, this package is the wrong tool.

No clock-skew leeway. Expiry is evaluated against server time with no grace window. Keep hosts on NTP; a few seconds of drift on a 60-second TTL will produce intermittent 401s. If you need leeway, add it to your own validation layer.

The package is quiet. Last release was April 2022 and there's no Laravel integration, so bugs and PHP-version bumps are on you. Pin an exact version in composer.json, and keep the JwtService wrapper thin so a migration is a contained change rather than a grep across controllers.

Extension points worth knowing: customPayload() for arbitrary claims, Token::getHeader() to inspect alg/typ during debugging, Token::validateSignature() and Token::validateNotBefore() when you need to check individual constraints, and the Parse class if you want to inspect a token without holding the secret (header only — never trust an unverified payload).

Debugging checklist for a rejected token, in order: decode the payload at jwt.io and look at exp/nbf → confirm the secret in the running container (config('services.jwt.secret'), not .env) → confirm you're calling both validate() and validateExpiration() → confirm the token is being read from Authorization: Bearer and not a cookie or query string → confirm server time with date. That sequence resolves nearly every case.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment