Token Mechanics
Token Mechanics
JWT access tokens, session-based refresh, and the complete token lifecycle. No refresh tokens, no rotation — just JWTs and a session table.
Access Token Format
Access tokens are standard JWTs signed with HMAC-SHA256. No library needed — the implementation is ~30 lines of PHP.
JWT Structure
A JWT is three base64url-encoded parts separated by dots:
header.payload.signature
header: {"alg":"HS256","typ":"JWT"}
payload: {"sub":"user-uuid","app":"app-slug","scopes":["profile.read"],"sid":"session-uuid","iat":1718899200,"exp":1718899800}
signature: HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)Payload Fields
| Field | Type | Description |
sub | UUID | Subject (user ID) |
app | string | Target application slug |
scopes | string[] | Granted scopes for this token |
sid | UUID | Session ID (links to sessions table) |
iat | unix timestamp | Issued at |
exp | unix timestamp | Expires at |
Full Token Example
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTkxNi00NDY2NTU0NDAwMDUiLCJhcHAiOiJ0b2RvLWFwcCIsInNjb3BlcyI6WyJwcm9maWxlLnJlYWQiLCJlbWFpbC5yZWFkIl0sInNpZCI6IjEyM2U0NTY3LWU4OWItMTJkMy1hNDU1LTY2NjU1NTQ0MDAwMCIsImlhdCI6MTcxODg5OTIwMCwiZXhwIjoxNzE4ODk5ODAwfQ.a1b2c3d4e5f6...
│ │ │
│ base64url(header) │ base64url(payload) │
└──────────────────────────┴────────────────────────────┘
.signature (base64url of HMAC-SHA256)Implementation (No Library)
core.auth implements JWT creation and verification in plain PHP. No third-party library, no Composer dependency. The logic is simple enough to audit by reading it once.
Creating a JWT
function createJwt(array $payload, string $secret, int $ttl = 900): string
{
$header = base64url_encode(json_encode([
'alg' => 'HS256',
'typ' => 'JWT'
]));
$payload['iat'] = time();
$payload['exp'] = time() + $ttl;
$payload = base64url_encode(json_encode($payload));
$signature = base64url_encode(
hash_hmac('sha256', "$header.$payload", $secret, true)
);
return "$header.$payload.$signature";
}Verifying a JWT
function verifyJwt(string $token, string $secret): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$header, $payload, $sig] = $parts;
// Recompute signature
$expected = base64url_encode(
hash_hmac('sha256', "$header.$payload", $secret, true)
);
// Constant-time comparison — prevents timing attacks
if (!hash_equals($expected, $sig)) {
return null;
}
$payload = json_decode(base64_decode($payload), true);
// Check expiry
if (!isset($payload['exp']) || $payload['exp'] < time()) {
return null;
}
return $payload;
}Base64url Encoding
Standard base64 uses
+, /, and =. JWT uses base64url which replaces these with -, _ and strips padding:function base64url_encode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}Why No Library?
- Full transparency. You understand every line. No hidden behavior, no version pinning, no supply chain risk.
- No
alg: noneattack. Libraries parse the header to decide which algorithm to use — attackers exploit this by setting"alg":"none". Your code doesn't read the header. It always HMAC-SHA256, always verifies, period. - No Composer dependency. One less package to maintain, audit, and update.
- ~30 lines. The entire JWT implementation fits in a single function pair. A library adds thousands of lines for the same result.
Security Notes
- Always use
hash_equals()for signature comparison. The==operator is vulnerable to timing attacks. - Always check
exp. An expired token is never valid, even if the signature is correct. - The secret must be strong. Generate it with
random_bytes(32)and store it in environment variables, not in code. - Never parse the header for algorithm selection. The algorithm is always HS256. If you ever need RS256 (asymmetric), that's a different code path, not a header-driven decision.
Token Verification (App-Side, No DB Hit)
Apps verify JWTs locally. No network call to core.auth, no database lookup.
function handleRequest(string $authHeader, string $secret): ?array
{
if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
return null;
}
$token = substr($authHeader, 7);
$payload = verifyJwt($token, $secret);
if (!$payload) {
return null; // Invalid or expired
}
return $payload; // Valid — use $payload['sub'], $payload['scopes'], etc.
}The shared secret is distributed to registered apps during app registration. It's stored server-side only, never exposed to the client.
Session-Based Refresh
There is no refresh token. The refresh mechanism is a session lookup.
> Console relay: Projects call the refresh endpoint through core.console (
POST /v1/auth/session/refresh with the project API key). The internal flow below describes core.auth's own endpoint that console relays to.How Refresh Works
Client: POST /session/refresh
Cookie: session_ref=<session-uuid>
(or mobile: X-Session-Ref: <session-uuid>)
│
▼
core.auth:
1. Read session_ref from cookie (or X-Session-Ref header)
2. Look up session row: SELECT * FROM sessions WHERE id = ? AND revoked_at IS NULL
│
├── Session found + not expired:
│ ├── Update last_activity
│ ├── Issue new JWT (same user, same app, same scopes)
│ └── Return { "access_token": "eyJ..." }
│
├── Session not found or revoked:
│ └── Return 401 { "error": "session_invalid" }
│
└── Session expired:
└── Return 401 { "error": "session_expired" }What the Client Does
Browser (SPA):
const ACCESS_TOKEN_TTL = 15 * 60 * 1000; // 15 minutes
const REFRESH_BUFFER = 2 * 60 * 1000; // 2 minutes before expiry
let refreshTimer;
function scheduleRefresh() {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(async () => {
const response = await fetch('https://core.console.medkronos.com/v1/auth/session/refresh', {
method: 'POST',
credentials: 'include' // sends session_ref cookie
});
if (response.ok) {
const { access_token } = await response.json();
// Update stored JWT
scheduleRefresh(); // Schedule next refresh
} else {
// Session invalid — redirect to login
window.location.href = '/auth/login';
}
}, ACCESS_TOKEN_TTL - REFRESH_BUFFER);
}
scheduleRefresh();Mobile (native):
// Store session_ref locally (Keychain / SharedPreferences)
// On JWT expiry:
$response = httpPost('https://core.console.medkronos.com/v1/auth/session/refresh', [], [
'Authorization' => 'Bearer ' . $projectApiKey,
]);
$newJwt = $response['access_token'];Why No Refresh Token?
A refresh token is a credential that proves the right to get a new access token. The session cookie (
session_ref) does exactly that — it's a reference to a session row in the database. If the session exists, you get a new JWT. If it doesn't, you don't.The difference between a "session cookie" and a "non-rotating refresh token" is purely semantic. But the absence of rotation is what keeps it simple:
- No token families
- No
replaced_bychains - No stolen-token detection via replay
- No "revoke entire family" logic
Just: does the session exist? Yes → new JWT. No → login again.
Token Lifecycle Summary
Login
├── core.auth creates session row (DB)
├── core.auth signs JWT (15 min TTL)
├── core.auth sets session_ref cookie (HttpOnly, Secure, 30 days)
└── Returns JWT to client
API Request
├── Client sends JWT in Authorization header
├── App verifies HMAC signature locally (no DB hit)
├── App checks exp (no DB hit)
└── Valid → proceed
Refresh (every ~13 minutes)
├── Client calls core.console (relayed to core.auth /session/refresh)
├── core.auth looks up session row in DB
├── Session valid → issue new JWT
└── Session invalid → 401, re-authenticate
Logout
├── Client calls core.console (relayed to core.auth POST /session/logout)
├── core.auth revokes session row
├── core.auth clears session_ref cookie
└── JWT expires naturally within 15 minutes
Global Logout
├── Client calls /session/logout-all
├── core.auth revokes ALL session rows for user
└── All JWTs expire within 15 minutesNext: Revocation & Security — blacklist, IP logging, session invalidation.