Laravel

laravelian

A Laravel API used to learn how JWT-based access/refresh authentication actually works, by building the signing and verification logic by hand instead of relying on a library.


What's done

Environment setup

PHP and Composer were first installed with:

/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"

This is Laravel's official one-line installer script for Linux. It drops a self-contained PHP binary (musl-built, no system dependencies) plus Composer into:

~/.config/herd-lite/bin

Separately, PHP was also installed the normal Arch way:

sudo pacman -S php

This puts a different, independent PHP binary at /usr/bin/php — its own php.ini, its own extensions.

This mattered in practice. Arch's pacman PHP package ships without database support built in:

  • SQLite → needs sudo pacman -S php-sqlite
  • PostgreSQL → needs sudo pacman -S php-pgsql

And even after installing those, PHP won't use them until their lines are uncommented in /etc/php/php.ini:

extension=pdo_sqlite
extension=sqlite3
extension=pdo_pgsql
extension=pgsql

Both of these had to be fixed before migrations would run.


Database connection

The project uses Postgres, set in .env:

DB_CONNECTION=pgsql
DB_HOST=...
DB_PORT=...
DB_DATABASE=...
DB_USERNAME=...
DB_PASSWORD=...

Gotcha hit along the way: DB_CONNECTION was commented out at first. Laravel silently fell back to its default (SQLite) even though the Postgres credentials were sitting right there, unused. Laravel only reads .env values that are actually uncommented and match a key it's looking for.


Database schema

database/migrations/0001_01_01_000000_create_users_table.php — Laravel's default users columns:

id, name, email (unique), email_verified_at, password, remember_token, timestamps

Plus one addition for this project:

$table->string('refresh_token', 64)->nullable()->index();

It exists in the schema already, but nothing in the app reads or writes it yet — see What's left → Refresh token rotation.


The hand-rolled JWT class — app/Support/Jwt.php

The core learning exercise: every part of how a JWT is built and checked was implemented from scratch, one method at a time, each tested individually before moving on.

base64url() / base64url_decode()

JWTs need to pack binary data (JSON text, signature bytes) into something safe for a URL or HTTP header. Standard base64 uses +, /, =, which aren't URL-safe.

  • Encode: standard base64 → swap +/ for -_ → trim trailing =
  • Decode: swap -_ back to +/ → re-pad to a multiple of 4 → base64_decode

(Padding can always be recalculated from the string's length — that's why it's safe to trim.)

sign($string)

The cryptographic proof that a token hasn't been tampered with:

hash_hmac('sha256', $string, $key, true)

$key is Laravel's own APP_KEY, reused here instead of managing a separate secret — stripped of its base64: prefix and decoded to raw bytes first.

The true fourth argument matters: it returns the raw 32 signature bytes, not a 64-character hex-text representation of them. Only the raw bytes are correct to feed into base64url() — encoding the hex text instead produces a technically-valid-looking but wrong signature that no standard JWT verifier would accept.

An HMAC isn't random, even though it looks like meaningless bytes — it's a deterministic function of the message and the secret key. Anyone can hash a message alone; only someone who knows the key can produce the correct HMAC for it. That's what makes tampering detectable.

encode(array $payload, $ttlSeconds = 900)

  1. Build header {"alg":"HS256","typ":"JWT"} → JSON-encode → base64url-encode
  2. Add exp to the payload (time() + $ttlSeconds, a plain Unix timestamp) → JSON-encode → base64url-encode
  3. Join header + payload with ., sign it, base64url-encode the signature
  4. Return header.payload.signature

decode(string $token): ?array

The verification counterpart. Fails safely (null) rather than throwing, checking in order and bailing at the first failure:

Step Check Why
1 Exactly 3 .-separated parts Reject malformed tokens before doing real work
2 Re-sign header+payload, compare to the 3rd part with hash_equals() (not ===) === exits early on the first mismatched byte, leaking timing info an attacker could exploit to guess bytes one at a time; hash_equals() always takes the same time
3 Reverse base64url, json_decode(..., true) Get back a real PHP array
4 exp exists and isn't in the past Expired = treated exactly like invalid

issueAccessToken() / issueRefreshToken() / issueTokens()

Thin wrappers around encode():

issueAccessToken($payload)   // 900s   (15 min)
issueRefreshToken($payload)  // 604800s (7 days)
issueTokens($payload)        // both, as ['access_token' => ..., 'refresh_token' => ...]

The two token types differ only in purpose and lifetime, not mechanics — both go through the same encode(). An access token rides along on every API request and stays short-lived so a leak does limited damage. A refresh token's only job is "this user doesn't need to type their password again right now," which is exactly why it lives longer and needs to be handled more carefully (see rotation, below).


Authentication middleware — app/Http/Middleware/JwtAuth.php

Any route wrapped in this middleware requires a valid token:

  1. Reads Authorization: Bearer <token> via $request->bearerToken()
  2. Passes it to Jwt::decode()
  3. null → 401 Unauthorized, immediately
  4. Valid → pulls the user ID from the payload's sub claim, calls Auth::onceUsingId() (authenticates this request only — no session persisted, appropriate for a stateless API)

Login endpoint — POST /api/login

AuthController::login() — one security property worth calling out explicitly: no email and wrong password return the exact same response.

{ "message": "Invalid credentials" }

— always 401, always that message, whichever case it was.

If the two cases returned different messages ("no account with that email" vs "wrong password"), an attacker could use the login form itself to check, email by email, which addresses have accounts — a leak called user enumeration. Same response for both closes that off.

The actual password check:

Hash::check($request->password, $user->password)

compares plaintext against the stored bcrypt hash — never plaintext-to-plaintext, never a reversible password anywhere.

On success: Jwt::issueTokens(['sub' => $user->id]), returned as JSON. Login always mints a completely fresh token pair — intentional and standard. Logging in proves identity from scratch via a password, so it always starts a brand-new session, regardless of whether old tokens from a previous session are still valid.


Protected test route — GET /api/me

Minimal route behind JwtAuth, just returns $request->user(). Exists purely to confirm the middleware + token flow work end-to-end.


What's left

POST /api/refresh — not built yet, next up

The most important missing piece. Right now the only way to get a new access token is logging in again with a password — defeating the point of having a refresh token at all. It should:

  • Accept only a refresh token (no email/password)
  • Verify it with Jwt::decode(), same as the middleware
  • Issue a new access token (and likely a new rotated refresh token) if valid

This is what lets a client stay "logged in" silently: once the 15-minute access token expires, the client calls /refresh with the still-valid 7-day refresh token instead of bothering the user to log in again.

Refresh token rotation and storage — not implemented

Every token right now — access and refresh — is fully stateless: Jwt::decode() only checks signature + expiry, with no database reference at all. A refresh token, once issued, stays valid for its full 7 days no matter what, with no way to revoke it early (lost device, suspected breach, etc).

The refresh_token column on users is the seed of a better design but isn't wired up yet. Recommended pattern: rotation — each /refresh call invalidates the token just used and issues (and stores) a brand new one. Bonus: if a stolen refresh token gets used by an attacker, and the real user's client later tries the same (now-rotated) token, the server can detect that reuse — a near-certain theft signal — and revoke all of that user's sessions. None of this is possible with the current stateless setup.

Registration endpoint — not built

No POST /api/register yet. The one test user was created manually via php artisan tinker.

Logout / revocation — not built

No way to invalidate a token before it naturally expires. Tied to the same gap as rotation above — server-side refresh token tracking would at minimum let you revoke refresh tokens on logout (access tokens are short-lived enough that this matters less for them).

Input validation — not implemented

login() reads $request->email / $request->password directly, no validation rules — no check that email is present or well-formed before hitting the database.

Automated tests — none yet

Everything so far (Jwt::encode/decode, login, the exp check, token tampering) was manually verified via php artisan tinker and curl during development, not captured as repeatable tests.

Longer-term consideration

Once the learning goal is met, it's worth knowing production systems almost never hand-roll this part:

  • firebase/php-jwt — focused library, does exactly what Jwt.php does, more robustly
  • tymon/jwt-auth — full JWT auth package
  • Laravel Sanctum / Passport — Sanctum sidesteps raw JWTs entirely, using database-backed tokens instead

Hand-rolled signing/verification code is a well-known source of subtle security bugs — several were caught and fixed during this project (an undefined variable silently passing a check, an inverted condition that disabled signature verification entirely, a timing-unsafe comparison). That's a good illustration of why battle-tested libraries win once past the learning stage.