Core Docs

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



FieldTypeDescription







subUUIDSubject (user ID)
appstringTarget application slug
scopesstring[]Granted scopes for this token
sidUUIDSession ID (links to sessions table)
iatunix timestampIssued at
expunix timestampExpires 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: none attack. 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_by chains

  • 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 minutes





Next: Revocation &amp; Security — blacklist, IP logging, session invalidation.