A guide to securing headless systems with JSON Web Tokens.

Introduction: Why JWT for Headless Security?

JWT headless app architecture diagram

Headless means your backend exclusively provides data without a presentation layer—for example, a PHP API that only returns JSON to a mobile app or JavaScript frontend. In such a stateless environment, traditional session authentication doesn't work smoothly: the server must process each request independently without tracking user sessions.

JSON Web Tokens (JWT) offer a compact, self-contained, and secure way to transmit authorization information. After logging in, the client receives a token that is sent with every API call, allowing your backend to easily verify if the user is authorized.

The Anatomy of a JWT

  • Header: Contains metadata such as the algorithm (alg) and the token type (typ).
  • Payload: Contains the "claims"—data about the user (sub), the issuer (iss), and the expiration (exp). You can also add roles or permissions here.
  • Signature: A cryptographic signature over the header and payload, using your secret key, to prevent tampering.

Part 1: The PHP Backend – Building a JWT API

In this part, we'll build the PHP backend that issues and validates tokens. We will use firebase/php-jwt to create and verify JWTs, ensuring strict claim and algorithm validation.

  • Structure:
    • api/login.php: Validates login credentials and returns a JWT (access + refresh).
    • api/refresh.php: Refreshes the access token using a valid refresh token.
    • api/secure-data.php: Secure endpoint; only accessible with a valid access token.
    • .env: Contains your secret keys (JWT_SECRET and JWT_REFRESH_SECRET).
  • Installation:
       
    composer require firebase/php-jwt

     

1. Generating a Token (login.php)

<?php
require __DIR__ . '/vendor/autoload.php';
use Firebase\JWT\JWT;

// Load secret from .env
$secret       = getenv('JWT_SECRET');
$refreshSecret= getenv('JWT_REFRESH_SECRET');

// Validate user...
if (validUser($_POST['email'], $_POST['password'])) {
    $now = time();
    $accessPayload = [
        'iss' => 'https://your-domain.com',
        'aud' => 'https://your-domain.com',
        'iat' => $now,
        'exp' => $now + 900,      // 15 minutes
        'sub' => getUserId(),
        'role'=> 'user'
    ];
    $refreshPayload = [
        'iss' => 'https://your-domain.com',
        'aud' => 'https://your-domain.com',
        'iat' => $now,
        'exp' => $now + 604800,   // 1 week
        'sub' => getUserId()
    ];
    $accessToken  = JWT::encode($accessPayload, $secret, 'HS256');
    $refreshToken = JWT::encode($refreshPayload, $refreshSecret, 'HS256');
    echo json_encode(['accessToken'=>$accessToken, 'refreshToken'=>$refreshToken]);
    exit;
}
http_response_code(401);
echo json_encode(['error'=>'Invalid credentials']);

2. Validating a Token (secure-data.php)

<?php
require __DIR__ . '/vendor/autoload.php';
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

$secret = getenv('JWT_SECRET');
$auth   = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (preg_match('/Bearer\s(\S+)/', $auth, $m)) {
    try {
        $token = $m[1];
        $decoded = JWT::decode($token, new Key($secret, 'HS256'));
        // Check iss and aud
        if ($decoded->iss !== 'https://your-domain.com' || $decoded->aud !== 'https://your-domain.com') {
            throw new Exception('Invalid token issuer or audience');
        }
        echo json_encode(['data'=>'Secure information']);
        exit;
    } catch (Exception $e) {
        http_response_code(401);
        echo json_encode(['error'=>'Access denied']);
        exit;
    }
}
http_response_code(401);
echo json_encode(['error'=>'No token found']);

3. Refresh Endpoint (refresh.php)

<?php
require __DIR__ . '/vendor/autoload.php';
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

$refreshSecret = getenv('JWT_REFRESH_SECRET');
$body = json_decode(file_get_contents('php://input'), true);
try {
    $decoded = JWT::decode($body['refreshToken'], new Key($refreshSecret, 'HS256'));
    $now = time();
    $newAccess = [
        'iss'=>'https://your-domain.com','aud'=>'https://your-domain.com',
        'iat'=>$now,'exp'=>$now+900,'sub'=>$decoded->sub
    ];
    $token = JWT::encode($newAccess, getenv('JWT_SECRET'), 'HS256');
    echo json_encode(['accessToken'=>$token]);
} catch (Exception $e) {
    http_response_code(401);
    echo json_encode(['error'=>'Invalid refresh token']);
}

Part 2: The Ionic Vue App – API Communication with Axios

In this part, we'll build the frontend in Ionic Vue. We will install Axios, create a service for all our API calls including refresh token rotation, and implement secure storage.

1. Secure Storage & Axios Setup

First, install the secure storage plugin for Capacitor:

npm install @capacitor-community/secure-storage
// src/services/AxiosService.js
import axios from 'axios';
import { SecureStoragePlugin } from '@capacitor-community/secure-storage';

const API_URL = import.meta.env.VITE_API_URL || 'https://your-domain.com/api';
const storage = new SecureStoragePlugin();

const axiosInstance = axios.create({ baseURL: API_URL, headers:{'Content-Type':'application/json'} });

axiosInstance.interceptors.request.use(async config => {
  const token = await storage.get({ key:'accessToken' });
  if (token.value) config.headers.Authorization = `Bearer ${token.value}`;
  return config;
});

export default axiosInstance;

2. Login & Token Storage (LoginPage.vue)

<script setup>
import { SecureStoragePlugin } from '@capacitor-community/secure-storage';
import axiosService from '@/services/AxiosService';
const storage = new SecureStoragePlugin();

async function handleLogin() {
  const resp = await axiosService.post('/login.php', params);
  await storage.set({ key:'accessToken', value:resp.data.accessToken });
  await storage.set({ key:'refreshToken', value:resp.data.refreshToken });
  router.push('/tabs/dashboard');
}
</script>

3. Refresh Logic

In your interceptor, you can automatically make a refresh call on a 401 error:

axiosInstance.interceptors.response.use(null, async error => {
  if (error.response?.status === 401) {
    const refresh = await storage.get({key:'refreshToken'});
    const { data } = await axios.post('/refresh.php', { refreshToken: refresh.value });
    await storage.set({key:'accessToken', value:data.accessToken});
    error.config.headers.Authorization = `Bearer ${data.accessToken}`;
    return axiosInstance.request(error.config);
  }
  return Promise.reject(error);
});

Best Practices & Security

  • Always use HTTPS for transport security.
  • Store tokens in secure storage (e.g., Capacitor Secure Storage), not in localStorage.
  • Implement access and refresh token rotation for short-lived sessions.
  • Always validate iss, aud, and alg on the server side.
  • Limit the token payload to the minimum necessary data.

Conclusion

This guide highlights that true security starts with a "security-first" architecture: short-lived tokens, a dual-token strategy, secure client-side storage, and strict server-side validation. With this layered approach, you can build a resilient, production-ready headless application that is resistant to modern threats.