A guide to securing headless systems with JSON Web Tokens.
Introduction: Why JWT for Headless Security?

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_SECRETandJWT_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, andalgon 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.

Steps to Download and Display a PDF in an Ionic Vue App (Android/iOS):