# Laravel API Authentication: Access Tokens, Refresh Rotation & HttpOnly Cookies

> Learn how to secure Laravel APIs with short-lived access tokens, refresh token rotation, HttpOnly cookies, Sanctum middleware, logout, and practical authentication security best practices.

- Author: Naimur Rahman Akash
- Published: 2026-10-12
- Updated: 2026-10-13
- Tags: Laravel, API, Authentication, Laravel Sanctum, PHP, API Security
- URL: https://naimur.site/blog/laravel-api-authentication-refresh-tokens

Laravel API Authentication: JWT-Style Access Tokens, Refresh Token Rotation, and Secure HttpOnly Cookies

Build a secure, scalable authentication system for Laravel APIs using short-lived access tokens, rotating refresh tokens, middleware, and modern security practices.

Authentication is one of the most important parts of any API. A poorly designed authentication system can expose user accounts, leak sensitive information, or allow attackers to reuse stolen tokens.

In this tutorial, we will build a practical authentication architecture using Laravel, Laravel Sanctum, and database-backed refresh tokens. We will cover token generation, refresh-token rotation, logout, protected routes, and common security mistakes.

«What you'll build: An API authentication system where access tokens expire quickly, refresh tokens are stored securely in "HttpOnly" cookies, and each refresh operation invalidates the previous refresh token.»

Table of Contents

1. "Understanding the Authentication Architecture" (#1-understanding-the-authentication-architecture)
2. "Why Short-Lived Access Tokens Matter" (#2-why-short-lived-access-tokens-matter)
3. "Installing Laravel Sanctum" (#3-installing-laravel-sanctum)
4. "Creating the Refresh Tokens Table" (#4-creating-the-refresh-tokens-table)
5. "Building the Refresh Token Model" (#5-building-the-refresh-token-model)
6. "Implementing Login" (#6-implementing-login)
7. "Protecting API Routes" (#7-protecting-api-routes)
8. "Implementing Refresh Token Rotation" (#8-implementing-refresh-token-rotation)
9. "Implementing Logout" (#9-implementing-logout)
10. "Testing the Authentication Flow" (#10-testing-the-authentication-flow)
11. "Common Security Mistakes" (#11-common-security-mistakes)
12. "Production Deployment Checklist" (#12-production-deployment-checklist)

---

1. Understanding the Authentication Architecture

A secure authentication system should separate the access token from the refresh token.

An access token authorizes API requests. A refresh token allows the client to obtain a new access token after the existing one expires.

Authentication flow

flowchart TD
    A[User submits credentials] --> B[Laravel validates credentials]
    B --> C[Issue short-lived access token]
    B --> D[Create refresh token]
    C --> E[Return access token in JSON]
    D --> F[Set Secure HttpOnly cookie]
    E --> G[Client calls protected API]
    G --> H{Access token valid?}
    H -->|Yes| I[Return API response]
    H -->|No| J[Call refresh endpoint]
    J --> K[Validate and rotate refresh token]
    K --> L[Issue new access token and refresh cookie]
    L --> G

Token responsibilities

Feature| Access token| Refresh token
Purpose| Authorize API requests| Obtain new access tokens
Lifetime| About 15 minutes| For example, 7 days
Storage| In-memory client storage where practical| Secure "HttpOnly" cookie
Sent with| "Authorization: Bearer ..."| Automatically sent as a cookie
Rotation| Reissued during refresh| Replaced on every successful refresh
Revocation| Revoke the relevant access token| Revoke the refresh-token record

Important: Laravel Sanctum issues opaque personal access tokens, not JWTs. This tutorial uses Sanctum for access-token authentication and implements refresh-token rotation separately. If you specifically require signed JWTs, the access-token implementation will be different.

2. Why Short-Lived Access Tokens Matter

Long-lived access tokens increase the damage caused by token theft. An attacker who obtains a valid token may be able to access protected endpoints until the token expires or is revoked.

A short-lived access token reduces this exposure.

For example, consider the following configuration:

Setting| Example value
Access-token lifetime| 15 minutes
Refresh-token lifetime| 7 days
Refresh-token rotation| Every successful refresh
Refresh-token storage| SHA-256 hash in the database
Cookie security| "Secure", "HttpOnly", "SameSite"
Transport| HTTPS only

These are example values, not universal requirements. Choose lifetimes according to your application's risk, user experience, and compliance needs.

Why not store both tokens in localStorage?

JavaScript can read values stored in "localStorage". If an attacker exploits a cross-site scripting (XSS) vulnerability, those tokens can potentially be stolen.

An "HttpOnly" cookie prevents JavaScript from reading the refresh token directly. However, it does not prevent the browser from automatically sending that cookie with requests.

That is why cookie-based authentication must also address CSRF (cross-site request forgery).

---

3. Installing Laravel Sanctum

This tutorial targets modern Laravel applications, including Laravel 11 and Laravel 12. Verify the exact commands and configuration against your installed Laravel version.

Install Sanctum:

php artisan install:api
php artisan migrate

If Sanctum is already installed, check your existing configuration instead of reinstalling it.

Publish the Sanctum configuration if your installation requires it:

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

Configure access-token expiration

In "config/sanctum.php", configure the expiration period in minutes:

'expiration' => 15,

This provides a global expiration limit for Sanctum personal access tokens. Sanctum also supports token-specific expiration dates, which can be set when creating a token.

For example:

$token = $user->createToken(
    'api-access',
    ['*'],
    now()->addMinutes(15)
);

Sanctum's authentication middleware validates the token, including its expiration. Do not assume that issuing a token automatically gives it a short lifetime unless your configuration or token-specific expiration enforces it.

---

4. Creating the Refresh Tokens Table

Refresh tokens should be revocable, expire independently, and be stored in a form that does not reveal the original token if the database is exposed.

Generate a migration:

php artisan make:migration create_refresh_tokens_table

Add the following schema:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('refresh_tokens', function (Blueprint $table) {
            $table->id();

            $table->foreignId('user_id')
                ->constrained()
                ->cascadeOnDelete();

            $table->char('token_hash', 64)->unique();

            $table->uuid('family_id')->index();

            $table->timestamp('expires_at')->index();

            $table->timestamp('revoked_at')->nullable();

            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('refresh_tokens');
    }
};

Run the migration:

php artisan migrate

Why store a token hash?

The client receives a cryptographically random refresh token, but the database stores only its SHA-256 hash.

If the database is leaked, an attacker cannot immediately use the stored hash as the original bearer token.

The "family_id" identifies a chain of rotated refresh tokens. This can help identify token reuse and revoke the entire token family if a previously invalidated token appears again.

---

5. Building the Refresh Token Model

Create the model:

php artisan make:model RefreshToken

In "app/Models/RefreshToken.php":

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class RefreshToken extends Model
{
    protected $fillable = [
        'user_id',
        'token_hash',
        'family_id',
        'expires_at',
        'revoked_at',
    ];

    protected function casts(): array
    {
        return [
            'expires_at' => 'immutable_datetime',
            'revoked_at' => 'immutable_datetime',
        ];
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

The model stores the refresh-token hash, expiration time, revocation state, and token family.

You can also add a relationship to "app/Models/User.php":

use Illuminate\Database\Eloquent\Relations\HasMany;

public function refreshTokens(): HasMany
{
    return $this->hasMany(RefreshToken::class);
}

---

6. Implementing Login

Create a controller:

php artisan make:controller Api/AuthController

The login endpoint should:

1. Validate the submitted credentials.
2. Authenticate the user.
3. Generate a random refresh token.
4. Store only its hash.
5. Return a short-lived access token.
6. Set the refresh token in an "HttpOnly" cookie.

Create a refresh-token helper

Inside "app/Http/Controllers/Api/AuthController.php", begin with the following imports and helper method:

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\RefreshToken;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Cookie;

class AuthController extends Controller
{
    private function createRefreshToken(
        User $user,
        ?string $familyId = null
    ): array {
        $plainToken = bin2hex(random_bytes(32));

        $refreshToken = RefreshToken::create([
            'user_id' => $user->id,
            'token_hash' => hash('sha256', $plainToken),
            'family_id' => $familyId ?? (string) Str::uuid(),
            'expires_at' => now()->addDays(7),
        ]);

        return [$plainToken, $refreshToken];
    }

    private function refreshCookie(string $token): Cookie
    {
        return cookie(
            '__Host-refresh_token',
            $token,
            7 * 24 * 60,
            '/',
            null,
            true,
            true,
            false,
            'lax'
        );
    }
}

The cookie settings mean:

- "__Host-" requires a secure cookie with the path set to "/" and no "Domain" attribute in supporting browsers.
- "Secure" restricts the cookie to HTTPS.
- "HttpOnly" prevents JavaScript from reading the cookie.
- "SameSite=Lax" helps mitigate some cross-site request scenarios.

If your frontend and API are on genuinely cross-site domains, cookie configuration and browser behavior require additional consideration. Do not blindly change "SameSite" to "None" without also enforcing CSRF defenses.

Add the login method

Add this method inside the same controller class:

public function login(Request $request): JsonResponse
{
    $credentials = $request->validate([
        'email' => ['required', 'email'],
        'password' => ['required', 'string'],
    ]);

    $user = User::where('email', $credentials['email'])
        ->first();

    if (
        ! $user ||
        ! Hash::check($credentials['password'], $user->password)
    ) {
        throw ValidationException::withMessages([
            'email' => ['The provided credentials are incorrect.'],
        ]);
    }

    [$plainRefreshToken] = DB::transaction(
        fn () => $this->createRefreshToken($user)
    );

    $accessToken = $user->createToken(
        'api-access',
        ['*'],
        now()->addMinutes(15)
    )->plainTextToken;

    return response()
        ->json([
            'message' => 'Login successful.',
            'access_token' => $accessToken,
            'token_type' => 'Bearer',
            'expires_in' => 900,
            'user' => $user,
        ])
        ->withCookie($this->refreshCookie($plainRefreshToken));
}

In a production application, consider rate-limiting login attempts, returning a generic authentication error, and excluding password hashes or other sensitive attributes from the user response.

The database transaction ensures the refresh-token record is created atomically. Access-token issuance occurs afterward; for stronger failure handling, you can issue both tokens within the same database transaction.

---

7. Protecting API Routes

Sanctum uses the "auth:sanctum" middleware to authenticate personal access tokens.

In "routes/api.php":

use App\Http\Controllers\Api\AuthController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:login');

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user', function (Request $request) {
        return response()->json([
            'user' => $request->user(),
        ]);
    });
});

The "throttle:login" limiter is an example named limiter; define it in your application's rate-limiter configuration or replace it with an appropriate configured limiter for your Laravel version.

A client calls a protected endpoint using the access token:

GET /api/user HTTP/1.1
Host: api.example.com
Authorization: Bearer YOUR_ACCESS_TOKEN
Accept: application/json

Laravel validates the token and exposes the authenticated user through "$request->user()".

When the access token expires, the client should request a new one using the refresh endpoint.

---

8. Implementing Refresh Token Rotation

Refresh-token rotation replaces an existing refresh token with a new one whenever the client refreshes its session.

A robust implementation must handle concurrent requests and reuse of invalidated tokens.

The rotation process

1. Read the refresh token from the cookie.
2. Hash it and look up its database record.
3. Lock the record during the operation.
4. Check whether it has expired or been revoked.
5. Revoke the old token and create a new token in the same family.
6. Issue a new short-lived access token.
7. Set the new refresh token in the cookie.

Add this method to "AuthController":

public function refresh(Request $request): JsonResponse
{
    $plainToken = $request->cookie('__Host-refresh_token');

    if (! is_string($plainToken) || $plainToken === '') {
        return response()->json([
            'message' => 'Unauthenticated.',
        ], 401);
    }

    $result = DB::transaction(function () use ($plainToken) {
        $tokenHash = hash('sha256', $plainToken);

        $refreshToken = RefreshToken::where(
            'token_hash',
            $tokenHash
        )->lockForUpdate()->first();

        if (! $refreshToken) {
            return ['error' => true];
        }

        // Reuse of a previously rotated or revoked token
        // can indicate that a token has been stolen.
        if ($refreshToken->revoked_at !== null) {
            RefreshToken::where(
                'family_id',
                $refreshToken->family_id
            )->whereNull('revoked_at')
                ->update(['revoked_at' => now()]);

            return ['error' => true];
        }

        if ($refreshToken->expires_at->isPast()) {
            $refreshToken->update([
                'revoked_at' => now(),
            ]);

            return ['error' => true];
        }

        $user = User::find($refreshToken->user_id);

        if (! $user) {
            $refreshToken->update([
                'revoked_at' => now(),
            ]);

            return ['error' => true];
        }

        $refreshToken->update([
            'revoked_at' => now(),
        ]);

        [$newPlainToken] = $this->createRefreshToken(
            $user,
            $refreshToken->family_id
        );

        $accessToken = $user->createToken(
            'api-access',
            ['*'],
            now()->addMinutes(15)
        )->plainTextToken;

        return [
            'error' => false,
            'refresh_token' => $newPlainToken,
            'access_token' => $accessToken,
        ];
    });

    if ($result['error']) {
        return response()
            ->json(['message' => 'Session expired. Please log in again.'], 401)
            ->withCookie(cookie()->forget('__Host-refresh_token'));
    }

    return response()
        ->json([
            'access_token' => $result['access_token'],
            'token_type' => 'Bearer',
            'expires_in' => 900,
        ])
        ->withCookie($this->refreshCookie(
            $result['refresh_token']
        ));
}

Important implementation notes

Concurrency: "lockForUpdate()" helps prevent two simultaneous refresh requests from successfully rotating the same token. Test this against your actual database engine and transaction isolation settings. If you use SQLite locally, verify the locking behavior against the production database, such as MySQL or PostgreSQL.

Reuse detection: If an old, revoked refresh token is presented again, the code revokes the remaining active tokens in that family. This is a security response, but it can also log out a legitimate user if requests race or an old browser request is replayed. The client should coordinate refresh requests so that only one refresh operation runs at a time.

Token expiry: The code uses a fixed seven-day refresh-token lifetime. If you need an absolute session lifetime, preserve the family's original expiration time rather than extending it on every rotation.

Failure handling: The example is a teaching implementation. A hardened implementation should include consistent cookie clearing, audit logging, careful handling of concurrent reuse detection, and explicit session-revocation policies.

Add the refresh route

In "routes/api.php":

Route::post('/refresh', [AuthController::class, 'refresh'])
    ->middleware('throttle:refresh');

Define the "refresh" rate limiter in your Laravel application before using this route in production.

Because browsers automatically send cookies, the refresh endpoint must have CSRF protection. Validate the request's origin against a strict allowlist and use a suitable CSRF-token strategy for your application. "SameSite" alone is not a complete CSRF defense.

---

9. Implementing Logout

Logout should revoke the current access token and the associated refresh session.

Add this method to "AuthController":

public function logout(Request $request): JsonResponse
{
    $user = $request->user();

    $plainToken = $request->cookie('__Host-refresh_token');

    DB::transaction(function () use ($user, $plainToken) {
        if ($plainToken) {
            $refreshToken = RefreshToken::where(
                'token_hash',
                hash('sha256', $plainToken)
            )->lockForUpdate()->first();

            if (
                $refreshToken &&
                $refreshToken->user_id === $user->id
            ) {
                RefreshToken::where(
                    'family_id',
                    $refreshToken->family_id
                )->whereNull('revoked_at')
                    ->update(['revoked_at' => now()]);
            }
        }

        $user->currentAccessToken()?->delete();
    });

    return response()
        ->json(['message' => 'Logged out successfully.'])
        ->withCookie(cookie()->forget('__Host-refresh_token'));
}

Register the route:

Route::middleware('auth:sanctum')->post(
    '/logout',
    [AuthController::class, 'logout']
);

This revokes the current Sanctum access token and the associated refresh-token family.

If the refresh cookie is missing, this implementation still revokes the current access token, but it cannot identify which refresh-token family should be revoked. Applications requiring device-level session management should track a session identifier and provide a dedicated session-revocation workflow.

Logout from all devices

To invalidate all refresh sessions for a user:

RefreshToken::where('user_id', $user->id)
    ->whereNull('revoked_at')
    ->update(['revoked_at' => now()]);

To invalidate all existing access tokens as well:

$user->tokens()->delete();

Use these operations in an appropriately authorized controller or service, such as a "log out all devices" action or a password-change security workflow.

---

10. Testing the Authentication Flow

You should test login, protected routes, refresh, logout, expiration, and token reuse.

Login request

POST /api/login
Content-Type: application/json
Accept: application/json

{
    "email": "user@example.com",
    "password": "your-password"
}

A successful response contains an access token:

{
    "message": "Login successful.",
    "access_token": "1|example-token-value",
    "token_type": "Bearer",
    "expires_in": 900,
    "user": {
        "id": 1,
        "name": "Example User",
        "email": "user@example.com"
    }
}

The browser also receives a "Set-Cookie" header for the refresh token. A real response contains additional user fields according to your model's serialization settings, so ensure sensitive fields are hidden.

Refresh request

POST /api/refresh
Accept: application/json

The browser sends the refresh cookie automatically when cookie and credential settings allow it. A successful response contains a new access token and a replacement refresh cookie.

For a JavaScript client using "fetch", a cross-origin request may require:

const response = await fetch(
    'https://api.example.com/api/refresh',
    {
        method: 'POST',
        credentials: 'include',
        headers: {
            'Accept': 'application/json',
            'X-CSRF-TOKEN': csrfToken,
        },
    }
);

The "csrfToken" must come from your application's actual CSRF-token mechanism. Do not invent a token or assume that this header is automatically valid.

Configure CORS to allow only trusted origins and credentials where required. Do not combine credentialed requests with a wildcard allowed origin.

Protected request

const response = await fetch(
    'https://api.example.com/api/user',
    {
        headers: {
            'Accept': 'application/json',
            'Authorization': `Bearer ${accessToken}`,
        },
    }
);

Logout request

const response = await fetch(
    'https://api.example.com/api/logout',
    {
        method: 'POST',
        credentials: 'include',
        headers: {
            'Accept': 'application/json',
            'Authorization': `Bearer ${accessToken}`,
            'X-CSRF-TOKEN': csrfToken,
        },
    }
);

Adapt the CSRF and CORS configuration to your frontend architecture. For a separate SPA using Sanctum's cookie-based session authentication instead of personal access tokens, follow Laravel's stateful SPA authentication approach rather than mixing the two approaches without a clear design.

Recommended test cases

- Valid credentials return an access token and set the refresh cookie.
- Invalid credentials do not create a session.
- A valid access token can access protected routes.
- An expired access token cannot access protected routes.
- A valid refresh token produces a new access token.
- The old refresh token cannot be reused after rotation.
- An expired or revoked refresh token is rejected.
- Logout revokes the current access token and refresh session.
- A user cannot revoke another user's session.
- CSRF and origin checks reject untrusted refresh requests.
- Concurrent refresh requests do not create multiple valid successors.

---

11. Common Security Mistakes

Mistake 1: Storing refresh tokens in localStorage

JavaScript can access localStorage. Prefer an "HttpOnly" cookie for browser-based refresh tokens, and use short-lived access tokens stored in memory where practical.

Mistake 2: Using tokens that never expire

Always define access-token and refresh-token expiration policies. Expiration alone is not a substitute for revocation.

Mistake 3: Forgetting refresh-token rotation

If a refresh token remains valid indefinitely after being used, a stolen token may continue to grant access. Replace tokens during refresh and invalidate the old token atomically.

Mistake 4: Ignoring CSRF

"HttpOnly" prevents JavaScript from reading a cookie; it does not stop the browser from sending it automatically. Use CSRF protections, strict origin validation, secure cookie attributes, and appropriate CORS settings.

Mistake 5: Using wildcard CORS origins with credentials

Never use "Access-Control-Allow-Origin: *" for credentialed browser requests. Allow only the specific trusted origins your application needs.

Mistake 6: Logging secrets

Never log passwords, raw access tokens, raw refresh tokens, cookie values, or authorization headers. Log safe identifiers, event types, timestamps, and relevant security metadata instead.

Mistake 7: Ignoring XSS

An "HttpOnly" refresh cookie reduces token theft through JavaScript, but XSS can still execute actions as the user. Apply output escaping, content security policies where appropriate, dependency updates, and careful handling of untrusted HTML.

Mistake 8: Returning sensitive user information

Use API resources or an explicit response array. Never expose password hashes, remember tokens, internal security flags, or sensitive profile fields.

Mistake 9: Failing to rate-limit authentication endpoints

Apply rate limits to login, refresh, password reset, and other sensitive endpoints. Consider additional controls for suspicious activity.

Mistake 10: Assuming revocation automatically invalidates every token

Revoking a refresh token does not automatically revoke a previously issued access token. Revoke both when required, and keep access-token lifetimes short.

---

12. Production Deployment Checklist

Before deploying the authentication system, verify the following:

- [ ] HTTPS is enabled across the frontend and API.
- [ ] Access tokens expire after a defined short period.
- [ ] Refresh tokens are cryptographically random and stored as hashes.
- [ ] Refresh-token rotation occurs atomically.
- [ ] Reuse of revoked tokens triggers an appropriate security response.
- [ ] Refresh cookies use "Secure", "HttpOnly", and an appropriate "SameSite" setting.
- [ ] Cookie-based endpoints have CSRF protection and strict origin validation.
- [ ] CORS allows only trusted origins.
- [ ] Login and refresh endpoints have rate limits.
- [ ] API responses exclude sensitive user fields.
- [ ] Logout revokes the appropriate access and refresh tokens.
- [ ] Security events are logged without exposing credentials or tokens.
- [ ] Concurrent refresh requests are tested.
- [ ] Database migrations and indexes are verified in production.
- [ ] Refresh sessions have an appropriate absolute maximum lifetime.
- [ ] Password changes and account compromise can invalidate existing sessions.

Conclusion

A secure Laravel API authentication system requires more than issuing a token after login. It needs a clear separation between access and refresh tokens, short access-token lifetimes, secure cookie handling, refresh-token rotation, revocation, and defenses against CSRF and XSS.

Laravel Sanctum provides a useful foundation for access-token authentication, while a database-backed refresh-token service gives you control over session lifetime and rotation.

The example in this tutorial is a starting point, not a drop-in security package. Before production deployment, add automated tests, verify your browser and database behavior, configure CSRF and CORS correctly, and review the implementation against your application's threat model.

Recommended next step: Build automated feature tests for token rotation and concurrent refresh requests before integrating this authentication system into your Vue, mobile, or third-party API clients.
