The constraint that decided everything
A fare inspector works a shift where the network is not. They stand at a bus door in a part of the city where the signal drops, and a passenger taps a card. The app has to answer one question, is this pass valid? in under a second, and it has to answer it whether or not there is a single bar of signal.
That constraint is the design. If verification needed a round-trip to the server, the product would fail exactly when it is used. So the rule is absolute:
Every fare decision is made from data already on the device. Nothing on the
scan path waits for the network.
Everything else in this article follows from taking that sentence literally.
If the card has to prove itself offline, then the proof has to travel on the card. The card carries a small signed statement, “this card belongs to subscriber X and the entitlement is valid until date Y”, and the inspector’s phone verifies that signature locally. No server in the loop.
Which immediately raises the question that sinks most offline schemes: if the phone verifies the signature, doesn’t the phone hold the key? And if the phone holds the key, can’t anyone who decompiles the app mint their own valid cards?
The mistake we did not make: HMAC
One of the first architectural decisions was choosing how a pass should prove its authenticity without a network connection. At first glance, a symmetric message authentication code (HMAC) seems like a natural fit:
// Avoid reusing an application encryption key for pass authentication.
$signature = hash_hmac('sha256', $payload, $applicationEncryptionKey);The problem is that HMAC uses the same secret to generate and verify authentication tags. If an inspector application must verify passes offline, it would also need access to that secret. Once a shared secret is distributed inside a mobile application, it should be treated as recoverable: anyone who extracts it could generate authentication tags that pass the same verification process.
The lesson is not that HMAC is insecure. It is that HMAC is unsuitable for this particular trust boundary. A verification client should be able to confirm authenticity without possessing the authority to issue credentials.
Key management matters, too. Production signing keys should be purpose-specific rather than reused from unrelated application security functions. A deliberate key-rotation strategy is also necessary so that compromised or retired keys can be handled without relying on a single, permanently embedded secret.
The fix: asymmetric signatures (Ed25519)
The correct tool is an asymmetric signature. The server holds a private key and signs; the inspector app holds only the matching public key and verifies. The public key verifies but cannot sign. You can print it on a billboard. Shipping it in a public APK is not a leak; it is what public keys are for.
For Mova Pass, I chose Ed25519 via PHP's ext-sodium, with dedicated signing keys and a key identifier in the payload to support key rotation. The signing key is separate from the application's general encryption key.
On the server, the signer takes the canonical payload bytes, excluding the signature itself, and signs them:
final class PassSigner
{
public function __construct(
private PayloadCodec $codec,
private SigningKeyProvider $keys,
) {}
public function sign(PassEntitlement $pass): string
{
[$keyId, $secretKey] = $this->keys->active();
// Encode the payload, including the signing key identifier.
$message = $this->codec->encode($pass, keyId: $keyId);
// Sign the exact bytes the verifier will authenticate.
$signature = sodium_crypto_sign_detached($message, $secretKey);
return $this->codec->withSignature($message, $signature);
}
}The PayloadCodec dependency is not incidental. During development, an inconsistency in expiry-date encoding meant the signer and reader represented the same entitlement differently.
The signatures were mathematically valid, but the offline reader could not verify them, because it was authenticating different bytes from the ones the server had signed. The problem was not Ed25519. It was the agreement between the two implementations about what exactly was being signed.
That distinction matters when a backend and a mobile application are written in different languages. Both sides need to follow one canonical payload specification, including field ordering, date representation, character encoding, and serialization. Shared test vectors should verify that both implementations produce the same bytes and agree on valid and invalid signatures.
Keep signing keys separate and fail safely when configuration is missing
Signing keys should be dedicated to credential issuance, stored through an appropriate secret-management mechanism, and kept out of source control and logs.
// Illustrative configuration, not a production implementation.
'signing_keys' => loadKeysFromSecureStorage(),The signing service should fail closed if the required signing-key configuration is missing or invalid, rather than silently using an unintended key. Key identifiers provide a way to associate credentials with the appropriate verification key as the system evolves.
The architectural boundary is the important part: the backend has the authority to issue signed credentials, while the inspector application receives only what it needs to verify them.
The payload has to be tiny
For the pilot, we selected an NFC tag with approximately 504 usable bytes of storage.
The credential needs to fit the signed statement, subscriber identifier, expiry information, key identifier, and a 64-byte Ed25519 signature, while leaving room for future fields. I measured the encoded payload during implementation to make sure it fit within the selected tag's capacity.
This is why we rejected a smaller-capacity option during planning: a payload of approximately 135 bytes on a 144-byte tag leaves very little room for future changes. A format that fits today can become a constraint as the credential evolves.
The verdict engine: order is the product
Verifying the signature tells you the card is authentic. It does not tell you the card is usable. A card can be genuinely signed and still be stolen, or lapsed, or issued under a key this device has not synced yet. The product is the function that turns all of those facts into a single verdict, and the order it checks them in is a central part of the product.
Here is the precedence, and why each step sits where it does:
type Verdict = 'valid' | 'forgery' | 'unknown' | 'blocked' | 'expired';
function decideVerdict(card: ScannedCard, device: DeviceState): Verdict {
// 1. Blacklist FIRST. A stolen card can carry a perfectly valid signature
// and an unexpired entitlement. If we checked expiry first, we would wave
// a stolen-but-current card straight through.
if (device.blocklist.has(card.serialHash)) return 'blocked';
// 2. Signature. Everything after this line trusts the chip's contents, so
// nothing after this line may run until the bytes are proven authentic.
const key = device.publicKeys.get(card.keyId);
if (!key) return 'unknown'; // see the note below: NOT 'forgery'
if (!verifyEd25519(card.message, card.signature, key)) return 'forgery';
// 3. The server snapshot is the authority on expiry, NOT the date on the
// chip. Renewal happens server-side, so a renewed subscriber's card still
// reads the old date and must be accepted on the strength of the snapshot.
const snap = device.snapshot.get(card.subscriberId);
if (snap) return snap.validNow ? 'valid' : 'expired';
// 4. Only when the snapshot has never heard of the card do we fall back to
// the chip's own expiry.
return card.expiry >= device.today ? 'valid' : 'expired';
}
Two decisions in there are worth their own paragraphs.
An unknown key id is unknown, never forgery. If a card is signed with a key this device has not synced, that is almost always an out-of-date device, not a counterfeit. Telling an inspector “counterfeit card” in front of a paying passenger, when the real problem is that the inspector skipped this morning’s sync, is a serious thing to get wrong. We say “unknown, re-sync” and let the passenger travel.
The snapshot beats the chip on expiry. This is the single most important schema decision in the system. If the card’s own date were authoritative, then renewing online would not update the card, and every renewing subscriber would have to physically re-tap their card against a writer to extend it. By making a server-side snapshot (synced to the device at the depot) the authority, renewal is a database write and the physical card never changes.
The precedence, stated as English, is: the worst true statement wins. A stolen card whose subscription has also lapsed reads blocked, not expired, because “blocked” is the more serious truth. That ordering is deliberate and it is tested against every pairwise combination.
Syncing: the two moments a connection is required
Offline is the expected state, not an error. The device needs a connection at exactly two moments, both of which happen at the depot where there is signal:
Before the shift: download the blocklist, the entitlement snapshot, and the public keys.
After the shift: upload the shift’s scans (for audit and analytics).
Between those two, every decision comes from memory. And the device refuses to work if it cannot trust its own data. the sync-readiness check blocks the scan screen in three cases:
never synced;
the last sync is older than the server’s the configured maximum sync age;
the device clock reads earlier than the last sync, physically impossible without tampering, and the one attack a purely offline check cannot otherwise see (roll the clock back, and an expired card looks current).
function readiness(SyncState $sync, int $now): Readiness {
if (!$sync->lastSyncedAt) return blocked('sync required');
if ($now - $sync->lastSyncedAt > $sync->maxAgeMs) return blocked('refresh required');
if ($now < $sync->lastSyncedAt) return blocked('time check required');
return ready();
}The reasoning behind blocking rather than degrading: an inspector working from a week-old blocklist is worse than one who cannot work at all, because the first silently waves through every card that was stolen since the list was downloaded. A refusal is honest; a stale pass is a hole.
The limitation of static NFC cards
A valid digital signature proves that the signed pass data was created by an authorized issuer. It does not prove that the data is being presented by the original physical card.
If someone copies the contents of a readable static NFC card onto another compatible tag, the copied data can carry the same valid signature. An offline verifier cannot distinguish the original from the copy based on that signature alone.
This distinction matters because an accepted tap can count as a trip. Our system includes an additional operational check: it checks whether the card has already been used for the same journey, on the same line, within a defined time window. This helps prevent duplicate trip records under those conditions, but it is not a cryptographic defense against cloning.
A cloned card could potentially still be accepted when its use does not match the duplicate-trip conditions. Furthermore, because inspectors may operate offline, separate devices cannot necessarily see each other's latest scans in real time. Synchronization can help identify suspicious usage patterns afterward, but detection is not the same as prevention.
A stronger approach is to use NFC hardware that supports dynamic cryptographic authentication. Rather than presenting only static signed data, the card generates authentication data that changes with each interaction. The verifier must validate this data correctly and enforce the protocol's freshness and replay protections. This can make simple copying substantially harder, although the complete design still needs to account for replay attacks and operational constraints.
The appropriate level of protection depends on the threat model, the cost of fraudulent use, and the operational requirements of offline inspection. The key engineering distinction remains: authenticating a pass and authenticating the physical card are two different problems.

A useful real-world comparison is Île-de-France Mobilités
A useful real-world comparison is Île-de-France Mobilités' Navigo system. When a Navigo Annual Pass is transferred to a compatible smartphone, the physical pass is deactivated and the entitlement is moved to the phone. Île-de-France Mobilités explicitly states that the pass must be active on only one medium at a time to prevent fraud. This illustrates an important principle in transit-system design: preventing the same entitlement from being used through multiple active media is a separate concern from verifying the authenticity of the data presented by a card.
In Mova Pass, operational controls provide an additional layer of protection against suspicious or repeated usage. These controls can help identify inconsistencies in recorded activity, but they cannot independently establish that a physical NFC card is genuine. This is an important limitation of offline-first systems: cryptographic verification, entitlement validation, and detection of repeated usage address different problems
What’s your take? Have you faced similar challenges when designing offline-first systems? I’d love to hear your thoughts. If you found this useful, feel free to share it or drop a comment below.



