MedVault: A Blueprint For Secure Medical QR Codes

MedVault: A Blueprint For Secure Medical QR Codes

QR codes are designed to be like postcards on lampposts. They are not designed to contain private information. They are meant to be openly scannable by anyone at all in full public view. As such, they are the least appropriate medium to be placing any kind of personal medical information in. Of all the things to choose, QR cards are the worst possible idea imaginable for anything meant to be private. It is an inversion of their purpose.

How Do "Smart" Health Cards Work?

The supposed "Gold Standard" of medical QR codes is the so-called "SMART Health Card Framework" (SHC). 16 US states have adopted it, as have Canada, the Cayman Islands, and Sydney, Australia.
VCI is a voluntary coalition of public and private organizations committed to empowering individuals with access to a trustworthy and verifiable copy of their vaccination records and other clinical information in digital or paper form using open, interoperable standards.

To achieve its purpose, the founding members of VCI collaborated to develop The SMART Health Cards Framework Implementation Guide and The SMART Health Cards Vaccination and Testing Implementation Guide.

This entire system is a disaster, to put it mildly. The process is documented publicly: Health Cards.ipynb

The security of the card is so weak, a teenager could walk through it. And they have.

TLDR: Data stored in the SMART Health Cards is digitally signed but it is not encrypted. Anyone who scan the QR code will be able to retrieve your full name, date of birth and information on your vaccination (including the date and location).

What's embedded in the QR is a numeric mode string prefixed with a custom protocol handler (shc, or smart health card):


This decodes to a compact base64-encoded JSON Web Token (JWT) which is in a newer format called JSON Web Signature (JWS) consisting of a header, signature, and a payload (the data).


If we verify the signature (and that is a big IF), we can get at the payload via any JWT decoder, and find a standard JSON document in plain text.

"entry": [{
  "fullUrl": "resource:0",
  "resource": {
    "resourceType": "Patient",
    "name": [{
      "family": "Anyperson",
       "given": [
         "Biggleston III"
     "birthDate": "1951-01-20"

Yes, it's really this bad.

What is this data?

A SMART Health Card is composed of a W3C Verifiable Credential, encoded as a JWT, with a Credential Subject that contains the FHIR version and a FHIR Bundle (See Modeling W3C Verifiable Credentials in FHIR.

What's (HL7) FHIR?

Fast Healthcare Interoperability Resources (FHIR, pronounced "fire") is a standard describing data formats and elements (known as "resources") and an application programming interface (API) for exchanging electronic health records (EHR). The standard was created by the Health Level Seven International (HL7) health-care standards organization.

This entire "offline" system has been brutally unpacked by the Electronic Frontier Foundation (EFF), describing just how staggeringly stupid and dangerous it is:

California has not identified other security and anti-forgery features. The only encryption or secure transfer is the public health authority signing the record with their private key. The QR code itself is not encrypted; someone who plans to use it should be aware of that. As to forgery risk, since anyone can make a QR code like the one discussed above, it is up to the operator of the QR scanner to check the public key of the signed data to make sure it is from a valid public health authority.

They even have a helpful developer portal to allow anyone to create their own:

The entire chain of custody relies on a set of authorised signing keys (presumably from an online or portable registry). These are used to verify the signature used to generate the QR code data. To work offline, every QR code scanner must download them and use them to verify each card.

To summarise:

  1. Anyone can create a QR code;
  2. Anyone can read the QR code;
  3. Anyone can decode the QR code data;
  4. The QR code contains private medical data;
  5. Anyone can create a QR scanner;
  6. Every QR scanner needs the list of authorised signing keys;
  7. Only QR codes signed by authorised issuers should be considered valid.

What could possibly go wrong?

The Dinner Party Problem

Suppose you are at a meal with a friend. Also at the dinner are 12 other people you're meeting for the first time. You have no idea who they are, but they seem pleasant. You sit on one side of the table, your friend sits on the other.

For some inexplicable reason, you want to show your electronic health card to your friend.

But ONLY your friend. No-one else.

So you pull out your phone and show them your QR code. At the same time, another person you don't know is taking pictures of the couple next to you.

In the photo, they accidentally capture you showing the QR code in their picture.

At that moment, your medical information has been stored on someone else's device. Two things can happen:

  1. Their device can read your QR code, or:
  2. Your QR code (and private medical data) is now stored elsewhere.

This is not a fringe or outlying scenario. Intentional eavesdropping is far more common than the accidental kind. It's the reason we have SSL/TLS secure connections for online payments.

So how do we ensure the information we want to give someone is for that recipient only? We use a secondary challenge to verify it. A password, of sorts.

However, that causes another serious problem.

The Medical Messenger Problem

Let's assume we have some private information we want to exchange with someone. In our scenario, a bike messenger is arriving to collect a folder of secret documents. A medical receptionist is behind a desk and has them stuffed under her desk.

To make sure it's the true recipient, and the right person doing the pickup, we've specified the messenger needs to provide a code when they arrive. They must say the password. Let's say the code is a number, like 1500.

The receptionist doesn't know the code. She just knows there must be one for her to release the secret documents. The pickup must happen within 30 minutes.

Simple, you might think. The messenger turns up, provides the password (1500), and the receptionist gives him the payload.

Dead wrong.

Imagine 5 different messengers arrive at the exact same time.

  • Messenger 1 has no code
  • Messenger 2 has code 1500.01
  • Messenger 3 has code 1500, arriving 1 min later
  • Messenger 4 has code 150
  • Messenger 5 has code 15000

She has to choose. How does she know who to give the secret documents to?

Discounting Messenger 1 is easy, because he has no code. But 2 and 3 have the same code. The others look similar.

So the challenge code (password) must be verified in some way. Not just a forgery-prone "signature", but a time-limited mathematical scheme.

The way we get around this is cryptography. The answer must solve a puzzle successfully and be the only one which can. Only someone who can understand the cipher or key to create the puzzle is able to decode it properly. All other answers will be wrong.

What we need to do is issue a puzzle, and give the means to the answer only to the true intended recipient. Anyone will be able to have a go at solving it, but we can recognise the true recipient because only they will be able to provide the key.

And if they store this in their head rather than on paper, it will never be hijackable by anyone else. The puzzle is public; the key is given only to one person.

Lessons From Espionage: Numbers Stations & The One-Time Pad

Exchanging secret information covertly is the domain of spies and their agents. Intelligence officers have been perfecting covert information transfer for centuries.

For example, a Dead Drop is when an agent "drops" secret information in a pre-agreed remote location, and provides a covert sign to their handler they should visit. In London, Soviet agents would drop an orange peel in a specific flowerbed. This would signal a new microfilm of secret documents had been taped to the underside of a park bench twenty miles away.

We will focus on an old technique: the Numbers Station.

Numbers stations are an old, unbreakable WWI method of correspondence used in espionage. The concept is simple: at a pre-arranged time during the day (say, 8.36am), a radio broadcast station publicly broadcasts a series of numbers. Anyone can tune into the frequency and hear them. They are meaningless to everyone but spies who possess a one-time pad (OTP); usually a sheet of paper with random numbers in groups of five or more digits.

Typically, the letters of the message are converted into numbers and are added to numbers from the notepad using a simple mathematical operation known as “false addition.” The result is then transmitted. The recipient uses the same page from his own one-time pad and extracts the plain text message by applying “false subtraction” to the encrypted message.

Decoding these messages is impossible without access to the one-time pads used to encrypt them.

The Numbers Station is analogous to a QR code. It is an entirely public broadcast anyone can tune into.

Lessons From Debit Cards: EMV Chip n' PIN

Modern bank cards contain an electronic chip and require a PIN code to authenticate a transaction. Named after “Europay, Mastercard, and Visa”, the first launch of the EMV system in 1986 aimed to stop the unauthorised use of debit and credit cards.

The chip and the terminal work together to create a unique, encrypted code called a token or cryptogram. This token is unique to the specific transaction taking place, and will only be used that one time. This number is created from information in the chip combined with information in the terminal, but using instructions contained only in the chip. This is a dynamic number, meaning it will be different for every transaction. It’s useless outside of that one transaction, and if anyone were able to copy it he or she wouldn’t be able to use it to make purchases with the card. That’s in contrast to the static information contained in a mag stripe, which is always there on your card and able to be copied.

And of course, only the owner of the card knows the PIN code which matches the card (or account number), meaning a successful entry of the PIN verifies ownership of the card itself.

Anyone can pick up a debit card which has fallen on the ground and try to use it fraudulently in a shop. However, when they do, they will be prompted for their challenge code (PIN).

The exchange of information at a chip terminal is analogous to negotiating access to an electronic health record (EHR). Your medical records are your bank account.


MedVault: Theory

Although they serve different purposes, both systems - Numbers Stations and CHIP cards - have common characteristics.

  1. The “sender’s” information is public (i.e. the radio broadcast, the plastic card).
  2. The means to complete the transaction are known only to the intended recipient (i.e. the PIN, the one-time pad).
  3. Each exchange is unique and cannot be replicated later.
  4. Each exchange is limited by time.
  5. The mechanism is simple and flexible enough for anyone to learn and use.

An offline system is impossible here. You cannot carry your bank account around with you. So it will have to be a "live" system.

The QR scanner must be presumed to be an untrustworthy actor. Although, conversely, we have to trust them - or at least, presume the instigator of the process does.

How would we normally do this in a machine environment? With Perfect Forward Secrecy or End-to-End encryption. We can't do that here because we need to keep things much, much simpler and faster, and it must involve a human keeping the secret in their head to protect it.


  1. When a person wishes to share some information, their device will self-generate a challenge PIN code and QR code image.
  2. This private PIN code should be given to the intended recipient only, in person.
  3. A matching public QR code should follow which can be scanned.
  4. To display the information from the QR code, the PIN code must be supplied - within a certain amount of time.
  5. Once the information has been access, the QR code and PIN challenge code should not work again.

In practice, this would mean generating a QR code and emailing it to a hospital. Then calling your doctor will the PIN code. When they scanned it and provided the PIN, it would reveal the information. If they carelessly left it in the medical lounge, an eavesdropper wouldn't be able to reveal it afterwards.


  • To generate a new QR code, we will ask a server to generate a cryptographic puzzle and a private PIN. It will be mapped server-side to a resource (e.g. a health record or object) any way we wish.
  • We can generate as many different URLs as we wish.
  • The URL will contain a puzzle as the last URI fragment, encoded as Base64.
  • We will embed the URL into a QR code as we like so anyone can access it.
  • The QR scanner will discover the URL, and send a HTTP POST request to it containing the challenge PIN code.
  • If no PIN code is included, the request will fail.
  • If the PIN code is wrong, the request will fail.
  • If the PIN code successfully resolves the puzzle, it will decrypt to a numeric time-limited one-time passcode (TOTP, see: encoded with a secret attached to the provider of the PIN.
  • If the TOTP is not valid (out of time), the request will fail.
  • If the TOTP is valid, the server will reveal the intended data.
  • The server will allow multiple "reveals" before the URL and PIN challenge auto-destruct/disappear.

In practice, this should happen in < 50ms. A QR scanner must know what to do with the URL after it discovers it in the QR code.

Here's the information we give to the developer generating QR codes:

MedVault is analogous to a debit card chip/pin transaction. This endpoint provides an unlimited number of debit cards (the puzzle), each of which has a corresponding PIN (the challenge code).

Users may wish to confidentially share the details of their last diagnostic test with a trusted medical professional. This endpoint creates a unique, cryptographically-secure HTTP POST url which only works for the number of times specified, for only up to 6hrs after it is generated. To access the data, the requesting 3rd party must also provide a challenge code which identifies they are the intended recipient of the information. The user must personally provide this challenge code to whom they wish to share their confidential medical data with.

Any 3rd party may use this URL, without authentication, with any equipment they like. If they are using a QR code scanner, the receiving application must include the challenge code to the HTTP POST url once it has been read/accessed.

A user may generate as many URLs as required, but at a maximum of 10/min.

And here's the information we give to the developer creating the QR scanner application:

MedVault is analogous to a debit card chip/pin transaction. Anyone may try to use a stolen card, but only a PIN in the mind of the owner can verify it is authentic at the terminal.

After a user has generated a new one-time URL for a time up to 6hrs later, they must provide its matching challenge code to the 3rd party recipient in person (or by other secure method) in order for them to solve the cryptographic puzzle it includes. How a client wishes to display the challenge code (visually or otherwise) is a matter for them. This is because QR codes are designed to be a public postcard, i.e. the polar opposite of secure transfer of confidential information. This URL is only available for a maximum amount of set times (default is once) and will disappear immediately after they are exceeded, or within the validity period - whichever comes first.

The URL may be given out as freely as necessary to whomever asks for it, as either a raw HTTP POST or QR image, but only the recipient with the correct challenge code - which is only known to the user volunteering the information and within their exclusive control - may successfully demonstrate they are entitled to receive it.

The threat model in use for this information transfer is MITM QR-code hijacking and old-fashioned eavesdropping; it is presumed multiple attackers are attempting to access the information simultaneously, and as such, no helpful information is given to any client if the request fails.

MedVault: Example Server-Side Implementation

We'll use PHP, as it's easy to understand and a widely-employed server-side language.

We are going to need the spomky-labs/otphp and spomky-labs/base64url packages, as well as the OpenSSL extension and milon/barcode.

Important: this assumes the mechanism to generate the QR code is authorised by a an authenticated user (via API auth).

Creating a Puzzle/Challenge

First, let's generate a pseudo-randomised PIN code challenge of 8 characters.

  protected $length = 8;

  protected $keyspace = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';

  private $challenge;
  public function generate () : string
    $pieces = [];

    $max = mb_strlen ($this->keyspace, '8bit') - 1;

    for ( $i = 0; $i < $this->length; ++$i )
        $pieces []= $this->keyspace[random_int(0, $max)];

    $this->challenge = implode ('', $pieces);

    return $this->challenge;

Second, let's generate a TOTP code which is valid for 6 hours (21600 seconds). For this will need a secret attached to the user or the record we are revealing (stored encrypted in the users table of our DB, for example). This is what we will be encrypting as our payload.

use OTPHP\TOTPInterface;
use ParagonIE\ConstantTime\Base32;

    public function create () : string
        return TOTP::create (
            21600,    // The period is now 6hrs
            'sha256', // The digest algorithm
            8  // The output will generate an 8-digit code

So now we have our PIN code (the answer), and the secret message inside (the TOTP code for the specific record).

Third, we need to generate the "puzzle".

protected $cipher = 'AES-256-CBC';

protected $hash =  'sha512';

private $signature;

  public function puzzle (string $pin_code, int $totp_code) : array
    // initialization vector (IV) has to be the same when encrypting and decrypting
    $iv = openssl_random_pseudo_bytes ( openssl_cipher_iv_length ($this->cipher) );
    $encrypted = openssl_encrypt ($totp_code, $this->cipher, $pin_code, OPENSSL_RAW_DATA, $iv);
    $this->signature = hash_hmac ($this->hash, $encrypted, $pin_code, true );

    return [
      'puzzle'    => base64_encode ($iv.$this->signature.$encrypted), 
      'challenge' => $pin_code, 
      'signature' => base64_encode ($this->signature)

All we've done here is to use a keyphrase (the PIN code) with OpenSSL to encrypt the TOTP. That will give us a Base64-encoded encryption string.

Fourth, let's store a record of this puzzle/PIN on the server for 6 hours. We can't store this is a session, but a database is a suitable alternative.

Cache::put (
  Base64Url::encode ($base64_encoded_puzzle_string), 
    'signature'   => $puzzle_signature, 
    'reveals'     => 3, 
    'minutes'     => 3600, 
    'expires_at'  => now()->addHours (6), 
    'successes'   => 0
  now()->addHours (6)

Finally, let's generate the URL and put it in the QR code.

use Base64Url\Base64Url;
use \DNS2D;

$url = route ('some.named.route', [
  Base64Url::encode ($base64_encoded_puzzle_string)

return [
  'url' => $url, // one-time secure URL
  'png' => DNS2D::getBarcodePNG($url, 'QRCODE'), // sample QR png data
  'challenge' => $pin_code, // secondary challenge PIN code

Note that in both examples we have to double-encode the URI segment here as typical Base64 contains characters which aren't going to work well in an HTTP request. It helps to obscure things a little more.

When we return this from an API endpoint, this is how it looks:

  "url" : "https:\/\/\/transfer\/some_peram\/record_uuid\/c29tZXRoaW5nIGluIGhlcmUgZm9yIHRoZSBhcnRpY2xlIGV4YW1wbGU",
  "challenge" : "ABCDE12345"

The client displaying the QR code is free to do as it wishes - present the URL in plain text, or use the PNG dara. Before it displays the QR code, it should present the PIN code on the screen needed to decode it.

What this output is:

  1. URL: a POST endpoint containing a puzzle segment at the end;
  2. PNG: a QR code with the URL already embedded, for convenience;
  3. The PIN challenge code to send in the body of the POST request to the URL which solves the puzzle.

Verifying a Challenge

Going the other way is slightly easier.

The QR scanner is going to ask for a challenge code, then put it together to send us a POST like so:

curl -X 'POST' \
  '' \
  -H 'Authorization: Bearer eyJ0eXAiOiJKV1....' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'challenge=ABCDE12345'

First, we'll want to implement some middleware on our API routing so we can create multiple endpoints with puzzles. One might be for a blood test result; another could be a negative flu test. Mostly, this is going to do validation on the URL itself and do some self-destructing:

public function handle (Request $request, Closure $next)
    if (! count ($request->segments()) || count ($request->segments()) < 3 )
      abort (404);

    if (!$puzzle || empty ($puzzle))
      abort (404, 'puzzle');

    if (! Cache::has ($puzzle) )
      abort (404);

    if (! $request->has ('challenge') )
      abort (403); 
      // notice we don't provide a 422 here. The client doesn't know there is a secondary code.

    if (! is_string ($request->input ('challenge')) || strlen ($request->input ('challenge')) != 8 || ! preg_match ('/^[A-Z0-9]*$/', $request->input ('challenge')) )
      abort (403); 
      // notice we don't provide a 422 here. The client doesn't know there is a secondary code.

    // do our checking

    return $next($request);

Our resolution logic is quite simple:

  1. Decode the puzzle back to Base64;
  2. Try to decrypt the Base64 with the supplied challenge code;
  3. If successful, check the embedded TOTP to see if it's valid.

Initially, let's strip away the URL-safety stuff.

use Base64Url\Base64Url;

$val_to_decrypt = Base64Url::decode ($puzzle)

Then let's test whether OpenSSL can decrypt it properly:

public function reveal ( string $puzzle, string $challenge, ?string $signed_as = null ) : ?string
  $decoded = base64_decode ( $puzzle );

  $iv = substr ( $decoded, 0, openssl_cipher_iv_length ($this->cipher) ); // initialization vector(IV) has to be the same when encrypting and decrypting
  $signature = substr ( $decoded, openssl_cipher_iv_length ($this->cipher), 64 );
  $encrypted = substr ( $decoded, openssl_cipher_iv_length ($this->cipher) + 64 );

  $message = openssl_decrypt ( $encrypted, $this->cipher, $challenge, OPENSSL_RAW_DATA, $iv );

  if ( $signed_as )
    // Notice a timing / anatomy attack is possible here. We split the string into multiple parts, so you can decrypt if you extract that.
    // Here we check the signature to prevent it.
    $signature = hash_hmac ( $this->hash, $encrypted, $challenge, true );

    if ( hash_equals ( $signed_as, $signature ) )
      return $message;

  return $message;

Penultimately, let's check the TOTP inside:

use ParagonIE\ConstantTime\Base32;

return TOTP::create (
    Base32::encode ('somesecretforthisrecord'),
    21600,    // within 6hrs
    'sha256', // The digest algorithm
    8         // Needs to be 8 digits
->verify ($content_of_puzzle);

And finally, if it's all OK, update the cache or database entry to reflect attempts and successful "reveals".

$gate = Cache::get ($puzzle);


if ( $gate['successes'] == $gate['reveals'] ) 
// already exceeded max reveals allowed, 
// relevant when reveals == 1 and successes == 0
  Cache::pull ($puzzle);
  return false;

if ( $gate['successes'] <= $gate['reveals'] ) 
// more to go, put it back in the cache
  Cache::put ($puzzle, $gate, $gate['expires_at']);
  return true;

// remove the url so it's no longer available (404) and only one-time
Cache::pull ($puzzle); 
return true; // display the confidential data

Only at this point do we reveal the information being requested, as we have verified the third party requesting the information is authorised to have it.

MedVault: An Example Client-Side Application

Clients are QR scanners: an authorised app in a doctor's surgery, hospital, or other trusted 3rd party. However, they must also be regarded with suspicion and not fully trusted. There is no way to know whether the exchange is being hijacked by the middleman betraying the trust of the person they are helping.

The design steps for a QR-reading application are also simple. Important: the QR reader must know a PIN is also required.

  1. Capture and read a QR code image to discover the embedded URL;
  2. Request the accompanying PIN be typed in.
  3. Make a live network POST request t the URL with the PIN code in the body.
  4. Interpret the response code for errors.
  5. Retrieve the data response, which will only appear once.

In this example, we assume the QR code is being retrieved in React Native.

scan_result = ( {data} ) => {
  // embedded URL will be in 'data' var
  return fetch (data, {
    method: 'POST',
    body: 'challenge='+this.state.text_input_containing_challenge_code,
    headers: {
      'Accept':  'application/json',
  .then(response => response.json())  // check for 200/401/403

  if ( this.state.permission_status === true )
    return (
      <View style = {styles.container}>

          onBarCodeScanned = {this.scan_result }
          style = {{ height:  DEVICE_HEIGHT/1.1, width: DEVICE_WIDTH}}>




The same thing could be represented in boring jQuery like so, assuming you managed to get the URL manually or from within the QR:

var retrieve = function ()

  $.ajax ({
    type: "POST",
    beforeSend: function(request) {
      request.setRequestHeader("Accept", 'application/json');
    data: { challenge: $('#challenge').val() }, // text input value
    success: function(data) {
      // do as you wish with the data here
    error: function () {
      // bad code or dead url


The simplest way to set this up without QR codes is to use a CNAME or aliased domain. For example, if your API call was:

  • POST{base64_puzzle}

You would create an alias with an HTML page to capture the PIN such as:

  • GET{base64_puzzle}.html

All you would have to do is read the URI params ({user123} and {base64_puzzle) and substitute them into your API endpoint URL.

Of course, you could embed the CNAME or alias URL into the QR code directly, which would load the HTML page or webview on the QR scanner screen for the client to tap the PIN code into (like an ATM).

The key to the client implementation is the end user (the person volunteering their information and/or providing access to it) only knows the PIN and keeps it in their head - not on paper. They can only give it to the intended recipient to read once.

Getting To The Finish Line

If the idea of a unique PIN code for QR scans seems simple, good. It's meant to be. That's an indication it's usable.

Why isn't anyone doing it already? They should be.

QR codes need a debit-card style transaction mechanism, and we can't carry our medical records around so they're portable. Few of us are security experts. QR codes are excellent for public information like food menus, but they are appallingly bad for private data.

Scanning a QR - if we absolutely have to do it - needs to be less than two seconds from beginning to end or its entirely pointless.

A better mechanism would involve portable encryption, but we're not even at the stage of any of that yet - we're still on JWTs.

Security's a pain-in-the-ass. It's designed to be. Good security makes it easier, and doesn't rely on obscuring things. It takes thought.

The problem is healthcare providers are incredibly bad at technology. but reasonable at privacy. And software companies are incredibly bad at privacy, but good at technology. They don't talk or understand one another. And the government is the worst of both.

Anyone can implement secure QR. The question is: why aren't they?