Back to blog
Laravel
Intermediate

Building Secure JWT Authentication in Laravel 11: A Practical Guide

JWT authentication in Laravel 11 can be secure and clean when done right. This guide covers setup, architecture, production code, and security best practices.

June 15, 202418 min read

Introduction

APIs power modern web and mobile applications, and authentication is the gatekeeper that decides who gets access to what. In the Laravel ecosystem, developers often choose between session-based auth, Laravel Sanctum, and JWT (JSON Web Token). This article focuses on building secure JWT authentication in Laravel 11 from scratch using a stateless approach suitable for REST and mobile APIs.

Laravel 11 simplifies the bootstrap process with a leaner application skeleton, streamlined configuration, and first-party support for commonly needed packages. However, JWT is not enabled by default. You must intentionally design token issuance, validation, refresh flows, and security controls.

By the end of this guide, you will understand the core concepts of JWT, how to architect a secure authentication layer in Laravel 11, how to write production-ready code, and how to avoid the most common security mistakes that lead to token theft and account takeover.

Table of Contents

Core Concepts

JWT authentication in Laravel 11 relies on a few foundational ideas. Understanding them prevents architectural confusion later.

What Is a JWT?

A JSON Web Token is a compact, URL-safe token format defined by RFC 7519. It contains a header, a payload, and a signature. The signature proves the token was issued by a trusted party and was not modified.

Stateless vs Stateful Auth

JWT is usually stateless: the server does not store active sessions. It validates the token cryptographically on every request. This scales well but makes immediate revocation harder unless you add a denylist or short expiration with refresh tokens.

Access and Refresh Tokens

A secure JWT implementation uses short-lived access tokens (e.g., 15 minutes) and longer-lived refresh tokens (e.g., 7 days). The refresh token should be stored server-side or in a secure HTTP-only cookie depending on your threat model.

Token Signature Algorithms

Common algorithms include HS256 (shared secret) and RS256 (public/private key pair). For most Laravel APIs, HS256 is acceptable if the secret is strong and stored securely. RS256 is better when multiple services must verify tokens without sharing the signing secret.

Architecture Overview

A secure JWT authentication system in Laravel 11 typically contains the following layers:

  • Routes for login, refresh, logout, and protected resources
  • An AuthController that validates credentials and issues tokens
  • A JWT service that encodes, decodes, and verifies tokens
  • Middleware that protects routes by validating the access token
  • Optional token denylist or refresh token storage for revocation
  • Laravel validation and exception handling for clean API responses

The request flow is simple: a client sends credentials, Laravel verifies them, issues a signed JWT, and the client sends that token in the Authorization header. Middleware decodes and validates the token before the controller runs.

Step-by-Step Guide

Step 1: Install Laravel 11

Create a new Laravel 11 project using the official installer or Composer.

composer create-project laravel/laravel jwt-laravel-11cd jwt-laravel-11

Step 2: Install a JWT Library

We use firebase/php-jwt because it is widely used and maintained. You can also use a Laravel-specific package, but understanding the raw flow is valuable.

composer require firebase/php-jwt

Step 3: Configure Environment Secrets

Add a strong JWT secret to your .env file. Generate it with a secure random string.

php artisan tinkerecho base64_encode(random_bytes(32));

Then add to .env:

JWT_SECRET=your_generated_secretJWT_ALGO=HS256JWT_TTL_MINUTES=15JWT_REFRESH_TTL_DAYS=7

Step 4: Create JWT Service

Create a service class to encapsulate token creation and validation.

Step 5: Create Auth Controller

Build login, refresh, and logout endpoints. Use Laravel validation and rate limiting.

Step 6: Create Middleware

Create middleware that reads the bearer token, verifies it, and sets the authenticated user.

Step 7: Register Routes

Define API routes in routes/api.php with appropriate middleware groups.

Real-World Examples

Consider a mobile app backend. The mobile client logs in with email and password, receives an access token and refresh token, and stores the access token in memory. When the access token expires, the client uses the refresh token to obtain a new one without prompting for credentials.

Another example is a microservice architecture. A gateway issues JWTs after login, and downstream services verify the signature using a shared public key or secret. This removes the need for each service to query a central session store.

In a SaaS product, JWTs can include tenant ID and role claims. Middleware can enforce tenant isolation by checking the claim against the requested resource.

Production Code Examples

Below is a realistic implementation. First, the JWT service.

namespace App\Services;use Firebase\JWT\JWT;use Firebase\JWT\Key;use Firebase\JWT\ExpiredException;use Illuminate\Support\Facades\Config;class JwtService{    public function issueAccessToken(array $claims): string    {        $secret = Config::get('app.jwt_secret');        $algo = Config::get('app.jwt_algo', 'HS256');        $ttl = (int) Config::get('app.jwt_ttl_minutes', 15);        $payload = array_merge($claims, [            'iat' => time(),            'exp' => time() + ($ttl * 60),            'type' => 'access',        ]);        return JWT::encode($payload, $secret, $algo);    }    public function decodeToken(string $token): object    {        $secret = Config::get('app.jwt_secret');        $algo = Config::get('app.jwt_algo', 'HS256');        try {            return JWT::decode($token, new Key($secret, $algo));        } catch (ExpiredException $e) {            throw new \App\Exceptions\TokenExpiredException();        } catch (\Exception $e) {            throw new \App\Exceptions\InvalidTokenException();        }    }}

Next, the authentication controller.

namespace App\Http\Controllers\Api;use App\Http\Controllers\Controller;use App\Services\JwtService;use Illuminate\Http\Request;use Illuminate\Support\Facades\Auth;use Illuminate\Support\Facades\RateLimiter;use Illuminate\Validation\Rule;class AuthController extends Controller{    public function __construct(private JwtService $jwt) {}    public function login(Request $request)    {        $request->validate([            'email' => ['required', 'email'],            'password' => ['required', 'string'],        ]);        $key = 'login:' . $request->ip();        if (RateLimiter::tooManyAttempts($key, 5)) {            return response()->json([                'message' => 'Too many login attempts. Try again later.'            ], 429);        }        if (!Auth::attempt($request->only('email', 'password'))) {            RateLimiter::hit($key, 60);            return response()->json(['message' => 'Invalid credentials'], 401);        }        $user = Auth::user();        $access = $this->jwt->issueAccessToken([            'sub' => $user->id,            'email' => $user->email,            'role' => $user->role,        ]);        return response()->json([            'access_token' => $access,            'token_type' => 'Bearer',            'expires_in' => Config::get('app.jwt_ttl_minutes', 15) * 60,        ]);    }}

The middleware that protects routes:

namespace App\Http\Middleware;use App\Services\JwtService;use Closure;use Illuminate\Http\Request;use Symfony\Component\HttpFoundation\Response;class JwtAuthMiddleware{    public function __construct(private JwtService $jwt) {}    public function handle(Request $request, Closure $next): Response    {        $header = $request->header('Authorization');        if (!$header || !str_starts_with($header, 'Bearer ')) {            return response()->json(['message' => 'Unauthorized'], 401);        }        $token = substr($header, 7);        try {            $payload = $this->jwt->decodeToken($token);        } catch (\Throwable $e) {            return response()->json(['message' => 'Invalid token'], 401);        }        $request->attributes->set('auth_user_id', $payload->sub);        return $next($request);    }}

Comparison Table

Choosing the right auth approach matters. Below is a comparison relevant to Laravel 11 APIs.

ApproachStateBest ForRevocationComplexity
Session AuthStatefulTraditional web appsEasyLow
Laravel SanctumStateful/StatelessSPA + APIEasyLow
JWT AuthStatelessMobile + MicroservicesHarderMedium

Best Practices

  • Use short-lived access tokens and refresh tokens.
  • Store secrets in environment variables, never in code.
  • Validate all claims including exp, iss, and aud if used.
  • Use HTTPS only; never transmit tokens over plain HTTP.
  • Apply rate limiting on login and refresh endpoints.
  • Log authentication failures without exposing sensitive data.
  • Use middleware to centralize token verification.

Common Mistakes

  • Storing JWT secret in version control.
  • Using algorithm none or accepting client-specified alg.
  • Putting sensitive data like passwords in the payload.
  • Setting excessively long token expiration.
  • Not validating token signature on every request.
  • Returning detailed errors that help attackers enumerate users.

Performance Tips

JWT is fast because it avoids database lookups on every request. However, you should still cache user lookups if you fetch full profiles from the database after token validation. Use Laravel's cache or a read replica for high-traffic APIs.

Avoid heavy claims in the token. Keep the payload small to reduce bandwidth. If you need rich user data, fetch it after authentication using the user ID from the token.

Security Considerations

Security is the most important part of JWT authentication in Laravel 11. Always use a strong secret and rotate it periodically. If using RS256, protect the private key with restricted file permissions.

Protect against refresh token replay by storing refresh tokens server-side with a one-time-use flag or rotation scheme. When a refresh token is used, issue a new one and invalidate the old.

Set secure, HTTP-only cookies if the browser is the client. For mobile, use the device secure storage. Never log full tokens in application logs.

Deployment Notes

On deployment, ensure your .env is not exposed. Use Laravel Octane or PHP-FPM with Nginx for production. Terminate TLS at the edge and force HTTPS redirects.

Use a process manager like Supervisor if running queued jobs for token cleanup. If you maintain a refresh token table, add a scheduled command to delete expired tokens.

Debugging Tips

  • Decode tokens locally using tinker to inspect claims.
  • Check server time skew if tokens expire immediately.
  • Verify the Authorization header format from the client.
  • Use Laravel's logging channel to capture middleware failures.
  • Test with Postman using a valid Bearer token.

FAQ

Is JWT better than Laravel Sanctum?

It depends. Sanctum is easier for Laravel SPAs and simple APIs. JWT is better for stateless microservices and cross-service verification.

Should I store JWT in localStorage?

For browsers, HTTP-only cookies are safer against XSS. localStorage is vulnerable to JavaScript theft. For mobile, use secure storage.

How do I revoke a JWT?

Use short expiration plus refresh tokens, or maintain a server-side denylist. Pure stateless JWT cannot be revoked without extra infrastructure.

What algorithm should I use?

HS256 is fine for single-team apps. RS256 is better when multiple services verify tokens without sharing a secret.

Can I use JWT for role-based access?

Yes. Include role claims in the payload and enforce them in middleware or policies.

Why is my token always expired?

Check server clock synchronization and that the exp claim is set in the future. Time skew between servers causes early expiration.

Do I need HTTPS for JWT?

Absolutely. Tokens sent over HTTP can be intercepted. Always enforce TLS.

How long should access tokens live?

Common practice is 5 to 15 minutes. Shorter is safer but increases refresh frequency.

Conclusion

JWT authentication in Laravel 11 is a powerful, scalable solution when implemented with discipline. Use short-lived tokens, secure secrets, strict middleware, and clear refresh logic. Start with the code in this guide, adapt it to your domain, and harden it using the security checklist provided.

If you want to go further, review our related articles on Laravel API rate limiting and REST API design, then implement JWT in a staging environment before production.