COVID certificate and other large NDEFs

I’m continuing this here from this post because it would become really OT in the other thread. @Pilgrimsmaster: maybe you could transfer the other post in here for consistency?

So, I have a nice short PDF that fits in a DESFire EV2. I figured I’d give burning it into one of my test cards a shot, to see how long it takes to read back, and I’m running into a really basic problem that I didn’t even think about: how to stick a PDF into an NDEF? :slight_smile:

Long story short, TagWriter doesn’t know what a PDF is and doesn’t know what to do with it. and application/pdf is not a standard NDEF mime type. But that’s not necessarily a problem, as the Android intent filter does handle nonstandard mime types - as evidenced by text/vcard which is handled perfectly.

So, I made a custom NDEF and wrote it to my test card with my trusty ACR122U. Then I read it back with my cellphone and… sure enough, Android has no idea what to do with application/pdf. Dammit…

So, nevermind the PDF idea then. I’m going to explore other formats that are handled natively by Android for the purpose of sticking a large self-contained COVID certificate onto a NFC chip. But PDF ain’t it. WEBP works, but the quality is godawful.

Also - and that’s quite problematic - reading back the 6,325-byte NDEF takes over 3 seconds, during which time the cellphone gives zero feedback or zero indication that it’s latched onto the NFC tag properly. For a cellphone that hits hard with a healthy NFC tag that provides good range, that’s already disturbing enough. But I’d hate to think what it would be like with a glassie and a bad cellphone trying to locate the chip…


You can use link to your website.
add .htpasswd protection to directory
and create a link in format

1 Like

That’s correct. But you missed the crucial bit:

for those of us who don’t want the recipient’s cellphone to hit the internet when our implant is scanned, either because there’s no internet, or because my COVID certificate has no business being on the internet.


:+1: :arrow_heading_down:

If anyone’s interested, here’s my latest effort to shrink the EU COVID vaccination certificate sample:

original_covid_certificate_template.pdf (178.0 KB)
compressed_covid_certificate_template.pdf (6.2 KB)

If you print both and superimpose them, you can’t tell them apart.

6.2 KB is well within the storage capability of a DESFire. But I think there’s still room for improvement with the encoding of the QR code. In the end, I ended up writing a script that generates the PDF directly with raw PDF directives. Not fun…

EDIT: here’s the script if you want to play with it - and the QR code GIF. You’ll need Python3 and the Reportlab library:


from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen.canvas import Canvas

from reportlab import rl_config
rl_config.invariant = 1

blue = (0, 0.2, 0.6)	# RGB
yellow = (1, 0.8, 0)	# RGB
white = (1, 1, 1)	# RGB
black = (0, 0, 0)	# RGB

canvas = Canvas("compressed_covid_certificate_template.pdf", pagesize = A4, pageCompression = 1)

canvas.setTitle("Mallitodistus EU:n koronarokotustodistuksesta")
canvas.setCreator("OpenPDF 1.3.24")
canvas.setProducer("OpenPDF 1.3.24")
canvas.setSubject("EU Digital COVID Certificate")


# Header
canvas.setFont("Helvetica-Bold", 22)
canvas.drawCentredString(53 * mm, 259 * mm, "EU Digital COVID")
canvas.drawCentredString(53 * mm, 247 * mm, "Certificate")
canvas.drawCentredString(53 * mm, 227 * mm, "EU:n koronatodistus")
canvas.drawCentredString(53 * mm, 215 * mm, "EU:s coronaintyg")

# EU flag background
canvas.rect(31.5 * mm, 175 * mm, 43 * mm, 32 * mm, stroke = 0, fill = 1)

# Blue text and links
canvas.setFont("Helvetica", 13)
canvas.drawString(6 * mm, 135.5 * mm, "VACCINATION CERTIFICATE / "
canvas.drawString(107.5 * mm, 206 * mm, "Testaaja, Matti Kari")
canvas.drawString(107.5 * mm, 192 * mm, "1995-05-20")
canvas.drawString(107.5 * mm, 173 * mm, "URN:UVCI:01:FI:"

canvas.drawString(111.6 * mm, 10.9 * mm, "")
		(111.6 * mm, 10.9 * mm, 151 * mm, 13 * mm), relative = 1)
canvas.drawString(6 * mm, 6.2 * mm, "")
		(6 * mm, 6.2 * mm, 55.5 * mm, 8.3 * mm), relative = 1)
canvas.drawString(95 * mm, 6.2 * mm, "")
		(95 * mm, 6.2 * mm, 144 * mm, 8.3 * mm), relative = 1)


# Yellow dividers
canvas.rect(10.5 * mm, 237 * mm, 84 * mm, 1.7 * mm, stroke = 0, fill = 1)
canvas.rect(106 * mm, 222 * mm, 94.5 * mm, 1.7 * mm, stroke = 0, fill = 1)

# EU flag stars
canvas.drawCentredString(52.75 * mm, 201 * mm, "\u2605")
canvas.drawCentredString(46.75 * mm, 199.3 * mm, "\u2605")
canvas.drawCentredString(58.75 * mm, 199.3 * mm, "\u2605")
canvas.drawCentredString(42.5 * mm, 194.9 * mm, "\u2605")
canvas.drawCentredString(63 * mm, 194.9 * mm, "\u2605")
canvas.drawCentredString(40.85 * mm, 189 * mm, "\u2605")
canvas.drawCentredString(64.65 * mm, 189 * mm, "\u2605")
canvas.drawCentredString(42.5 * mm, 183.1 * mm, "\u2605")
canvas.drawCentredString(63 * mm, 183.1 * mm, "\u2605")
canvas.drawCentredString(46.75 * mm, 178.7 * mm, "\u2605")
canvas.drawCentredString(58.75 * mm, 178.7 * mm, "\u2605")
canvas.drawCentredString(52.75 * mm, 177.2 * mm, "\u2605")

# EU flag "FI" marking
canvas.drawCentredString(53 * mm, 188 * mm, "FI")

# QR code
canvas.drawInlineImage("qr_code.gif", 133 * mm, 233.5 * mm,
			width = 48.5 * mm, height = 48.5 * mm)

# Black bold text
canvas.setFont("Helvetica-Bold", 11)
canvas.drawString(107.5 * mm, 214.5 * mm, "Surname(s) and forename(s):")
canvas.drawString(107.5 * mm, 200 * mm, "Date of birth:")
canvas.drawString(107.5 * mm, 186 * mm, "Unique certificate identifier:")

canvas.drawString(6 * mm, 125.2 * mm, "Disease or agent targeted:")
canvas.drawString(6 * mm, 115.4 * mm, "Vaccine:")
canvas.drawString(6 * mm, 105.4 * mm, "Vaccine medicinal product:")
canvas.drawString(6 * mm, 95.5 * mm, "Vaccine marketing authorisation holder:")
canvas.drawString(6 * mm, 85.7 * mm, "Number in a series of vaccinations/doses "
					"and the overall")
canvas.drawString(6 * mm, 81.7 * mm, "number of doses in the series:")
canvas.drawString(6 * mm, 63.4 * mm, "Date of vaccination, indicating the date "
					"of the latest dose")
canvas.drawString(6 * mm, 59.2 * mm, "received:")
canvas.drawString(6 * mm, 44.7 * mm, "Member state of vaccination:")
canvas.drawString(6 * mm, 30.7 * mm, "Certificate issuer:")

# Black text, certificate
canvas.setFont("Helvetica", 11)
canvas.drawString(107.5 * mm, 210 * mm, "Nimi/Namm:")
canvas.drawString(107.5 * mm, 195.8 * mm, "Syntymäaika/Födelsedatum:")
canvas.drawString(107.5 * mm, 182 * mm, "Todistuksen yksilöllinen tunniste / "
					"En unik indetifierare för")
canvas.drawString(107.5 * mm, 177.7 * mm, "intyget:")

canvas.drawString(6 * mm, 121 * mm, "Tauti tai taudinaiheuttaja / "
					"Sjukdom eller smittämne:")
canvas.drawString(6 * mm, 111.4 * mm, "Rokote/Vaccin:")
canvas.drawString(6 * mm, 101.4 * mm, "Rokotevalmisteen kauppanimi / "
					"Vaccinets handelsnamn:")
canvas.drawString(6 * mm, 91.5 * mm, "Myyntiluvan haltija / "
					"Innehavare av försäljningstillstånd:")
canvas.drawString(6 * mm, 77.7 * mm, "Saadut rokoteannokset ja "
					"tarvittavien annosten")
canvas.drawString(6 * mm, 73.4 * mm, "kokonaismäärä / Givna vaccindoser "
					"och det totala antalet")
canvas.drawString(6 * mm, 69.2 * mm, "doser som behövs:")
canvas.drawString(6 * mm, 55.0 * mm, "Viimeksi saadun rokotteen antopäivä / "
					"Datum för den senaste")
canvas.drawString(6 * mm, 50.7 * mm, "vaccinationen:")
canvas.drawString(6 * mm, 40.7 * mm, "Jäsenvaltio, jossa rokotus on saatu / "
					"Medlemsstaten där")
canvas.drawString(6 * mm, 36.7 * mm, "vaccineringhar getts:")
canvas.drawString(6 * mm, 26.7 * mm, "Todistuksen antaja / "
					"Utfärdare av intyget:")

canvas.drawString(110.2 * mm, 125.2 * mm, "COVID-19")
canvas.drawString(110.2 * mm, 115.4 * mm, "COVID-19 vaccines")
canvas.drawString(110.2 * mm, 111.4 * mm, "Covid-19-rokotteet / "
						"Vaccin mot covid-19")
canvas.drawString(110.2 * mm, 105.4 * mm, "Comirnaty")
canvas.drawString(110.2 * mm, 95.5 * mm, "BioNTech Manufacturing GmbH")
canvas.drawString(110.2 * mm, 85.7 * mm, "2 / 2")
canvas.drawString(110.2 * mm, 63.4 * mm, "2021-03-05")
canvas.drawString(110.2 * mm, 44.7 * mm, "Finland")
canvas.drawString(110.2 * mm, 40.7 * mm, "Suomi")
canvas.drawString(110.2 * mm, 30.7 * mm, "The Social Insurance Institution "
						"of Finland")
canvas.drawString(110.2 * mm, 26.7 * mm, "Kela/Fpa")

# Black text, footer

canvas.drawString(6 * mm, 20.6 * mm, "This certificate is not a travel "
					"document. The scientific evidence on "
					"COVID-19 vaccination, testing and "
					"recovery continues to evolve, also in")
canvas.drawString(6 * mm, 15.6 * mm, "view of new variants of concern of the "
					"virus. Before traveling, please check "
					"the applicable public health measures "
					"and related restrictions")
canvas.drawString(6 * mm, 10.9 * mm, "applied at the point of destination. "
					"Relevant information can be found "
canvas.drawString(152.5 * mm, 10.9 * mm, "Lue lisää koronatodistuksesta:")
canvas.drawString(56.6 * mm, 6.2 * mm, "Läs mer om coronaintyget:") (963 Bytes)


This is part of the reason we missed you @Rosco

Clever shit like this. :arrow_double_up:

Just dropping a @Satur9 here, because your post may be of interest to him


Keep missing me :slight_smile: I’m not back - just posting something technical someone might be interested in.



1 Like

Unless I’m missing something, iOS won’t be able to natively read it though correct?

Not trying to be a wet blanket, just pointing out that in theory half the phones won’t work with it… assuming I’m not wrong in which case derp

I made a pretty convincing HTML version that fits on an 8K DESFire EV2, but… Android doesn’t process text/html neither. Dammit… (4.3 KB)


I don’t know if iOS will read it or not. I don’t know if it’s just me, but I just don’t know anybody who owns an Apple phone :slight_smile: I know they’re supposed to be out there, and with a respectable market share too, but I just don’t seem to see any.


This doesn’t really relate to your intended use, but I’ve been keeping a keepass database with the credentials to offsite backups, base64 encoded, as an NDEF text record. I’d love to know if there’s a better way to store arbitrary files.

Record type TNF_UNKNOWN is equivalent to application/octet-stream - i.e. plain old binary data. The trick is, you have to have an application that makes use of it.


That’s fair,
I just see trying the concept of trying to store stuff local, for the purpose of it “always working”
Which is kinda moot if it doesn’t work for half of the phones out there

As far as the distribution of phone models, I guess that mileage may vary

I wish apple would chill out and let stuff just work universally, regardless of if I have an IPhone or not it effects the eco system negatively

Worldwide it is actually closer to 75% in favour of Android ( Japan and USA are the main exceptions @ ~60% iOS )

  • various sources, but that is pretty much the most frequently stated

I’m leaving this post here for future references, for people who want down the same rabbit hole of trying to tranfer arbitrary files and do something useful in a “universal” manner with cellphones. Note that I don’t have access to an Apple phone, so this only applies to Android.

This is essentially a technical summary of my failures :slight_smile:

  1. First, a reminder of what I’m trying to do

I want a generic non-vCard, non-picture document (text, PDF, HTML…) stored on an NFC tag to get opened by a stock, unmodified Android system, without any helper application.

  1. Quick summary of how Android handles NFC tags

This is extremely condensed and overly simplified, but should get you searching for the right things if you want to experiment with this.

So when you bring an NFC tag to a cellphone and the cellphone’s chip is able to read it (not always guaranteed - Mifare M1k for example aren’t always read), the phone reads the content of the tag and hands it over to the tag dispatch system.

The tag dispatch system handles the content according to a list of intent filters that NFC-aware apps have registered as part of the app manifest. An intent tells Android which application is interested in what NFC content.

From high-level to low-level, intents may be:

  • ACTION_NDEF_DISCOVERED: the tag contains an NDEF message with one or more NDEF records.

    The record types may be of various types (URI - well known or unknown, MIME, AAR…) The intent tells Android the activity wants to run when a tag contains an NDEF with this-or-that type of record, with this-or-that type of URI or MIME…

    AAR (Android Application Record) is handled directly by Android, and either runs a pre-install activity, or redirect to the Google Play store.

    Although you’ll find a variety of bit and byte encodings, and .h-style definitions in the NFC literature, record types are often simply written in URN notation, and many NFC apps, libraries and APIs (including Android’s) use the URN notation. So for example:

    urn:nfc:wkt:U is the URN for NFC, Well-Known Type, URI
    urn:nfc:wkt:T is the URN for NFC, Well-Known Type, Text is the URN for NFC, EXTernal, type - i.e. an AAR

  • NFC_TECH_DISCOVERED: If no suitable intent is found for any of the NDEF records (or the tag doesn’t contain an NDEF), the dispatcher looks for intents that trigger on different NFC tag technologies. For instance, on my phone, if I scan a DESFire without any NDEF, it automatically opens my local public transport app, because it thinks any cryptic-looking DESFire is bound to be my bus card.

  • NFC_TAG_DISCOVERD: if the dispatcher found no suitable intent for the tag technology (or the technology is unknown but still read for some reason), the dispatchers looks for any app that registered this low-level intent.

What we’re interested in is exploiting the NDEF intent mechanism of course.

The key to getting a stock, unmodified Android system to open an NDEF record properly is finding out which native Android apps or activities have registered NDEF intents with MIME types that interests us.

  1. What I’ve tried so far

I know of only two kinds of MIME types a stock Android system handles directly without any third-party app:

  • text/vCard and text/x-vCard: if you scan a tag that contains a NDEF record with that MIME type and properly formed vCard, the contact manager opens and handles the vCard. Useful to share contact details.
  • image/bmp, image/gif, image/jpe, image/jpg, image/jpeg, image/png, image/pnm, image/webp, image/heif: if you scan a tag that contains an NDEF record with that MIME type and a properly formed raster image, the gallery opens and displays the image. Useful to share low-res porn I guess…

That’s it. Maybe there are more obscure MIMEs that Android handles directly, but none that seem useful for the purpose of opening a document like a COVID vaccination certificate.

Specifically, I tried application/pdf and text/html (with properly formed documents in the record of course, but Android displays the boilerplate useless “NDEF record found” popup.

I also tried “text/plain”, and non-standard variations such as “text/htm”, “html/html”, but it always brings out the boilerplate popup.

Then I tried urn:nfc:wkt:U, but instead of passing a URL, I passed the document as a data:, in hope that Android would fire up the browser and pass it the data directly. Alas, Android specifically ignores URIs that aren’t of the well-known type (http[s]://[www]), and data: is an unknown type URI.

(If you don’t know what I’m talking about, fun fact: if you pass a base64-encoded string as a data: URI to the browser, it displays it. Try it: copy/paste this in your browser bar: data:text/html;base64,PGh0bWw+PGJvZHk+SGVsbG8gRGFuZ2Vyb3VzVGhpbmdzISE8L2JvZHk+PC9odG1sPgo= :slight_smile: )

I also tried to add a second AAR record to my NDEF (type, payload after the first text/html record. AAR records should always come last, but Android always reads all the records to find an AAR. The hope here was to get Android to fire up the browser and hand it the first text/html record for processing. Alas again, Android does fire up the browser, but it just sits there and doesn’t open anything.

Sure enough, I discovered after some googling that AARs are just there to redirect you to the Google Play store if you don’t have the right app installed that registers the intent for the first record, If the app isn’t ther, you can download it. If it’s already there, it’s handling the first record and the AAR is simply ignored. And of course, the browser doesn’t care about NFC tags, so that doesn’t work.

I also tried to put the AAR first - which is normally a no-no - just in case Android would pass the second NDEF record to the browser if it ran first, to no avail.

Also, incidentally, even if the AAR method worked, I don’t like it because it very specifically ties your NDEF to a particular browser on Android. But I might as well have tried it.

So that’s where I’m at. I’m still going down the list of vaguely useful MIME types, in the hope that native Android activity might be triggered and exploited, but I’m not too hopeful.

I’m also trying to think of other devious ways to get Android to act on my record in a somewhat portable way, but Android seems pretty well buttoned up to be as useless as possible with NFC tags.


Okay, kind of making progress here. Well not really, but I found something that works in a third-world sort of way.

So what I did is abuse the NFC Forum smart posters specification. Smart posters are yet another marketing gimmick to foster the impulse-purchase, oh-shiny!-based economy. They’re really kind of shit and nobody uses them, but they exist, Android understands them (Apple phones too presumably, but perhaps not…) and they can be used to mix text, images and links from an NDEF.

Check out how the sample COVID vaccination certificate renders on a bare-bone phone:


It’s ugly, but at least all the information is there. It’s just that it looks like the ungodly child of a Geocities webpage and an Apple II, and for something that’s supposed to look “official”, it’s not very convincing. Still, it works.

The good news is, the NDEF is much more lightweight at 4,700 bytes, because the images are encoded directly in binary format, and the text has no formatting. I bet with further image compression, it could fit on a DESFire EV1 with 4 KB. It loads quicker too.

If you want to generate the NDEF yourself, here’s the script I wrote, and the images: (5.9 KB)

It works in Linux. Sorry I don’t have Windows. Also, you need to git-clone nfcpy in the directory (git clone

EDIT: actually, here’s the raw NDEF if you want to write it as-is on a card: (4.4 KB)


Thanks a lot for the detailed analysis!

You’re welcome :slight_smile:

I’ve adopted a hybrid approach to this COVID certificate thing: the tag should contain the smart poster version of the certificate, the URI of which points to my mini HTML facsimile of it, which itself contains a link to the original PDF - both the HTML and the PDF being hosted on my web server.

This way, if there’s no internet where I happen to get my implant scanned, the smart poster is the default option. If there is internet and the recipient is suspicious, the HTML readily opens in any cellphone from the link in the smart poster if the recipient clicks on it. And if the recipient is really very, very picky about the origin of the document, they can get the vanilla thing from the Finnish health authorities provided their cellphone opens PDFs.

All I have to do now is find some poor soul who owns a (fairly recent) Apple phone to try out the smart poster, and give it a whirl on my older test Android cellphones as well.

Also, what this tells me is that I need a flexDF. Damn… Another implant in the queue. That’s 5 in total: Lassi will be busy :slight_smile: Amal: if I order a flexDF, can I instruct that it be shipped with my previous order when it’s ready in the order note?

EDIT: smart posters are read perfectly fine down to Android v5. It’s quite a nice format actually! Too bad the rendering sucks donkey ass…


Quick follow-up:

I went through and tested all the mimetypes understood by Android, but sadly, I am now forced to conclude that only vCards, plain text, raster images, URIs starting with “http” and combinations of them in a smart poster are read natively by a vanilla Android system. Quite a wasted afternoon…

Since I’m stuck with smart posters, I investigated ways to get the most out of them. It’s a very poor format (it was never meant to do what I’m trying to do with it in fairness…) so there isn’t much room for improvement.

But I did find a way to jazz up the rendering a bit: I noticed the text records are explicitely UTF8-encoded. That means it understands and displays Unicode. So I replaced a few characters with fancy Unicode equivalents, added a couple of syringe emojis (it is a vaccination certificate after all :slight_smile:) and a few bullet point characters, and it does look a bit less boring now:


Also, I fine-tuned the image compression and now the entire NDEF fits in 4KB. So it’s suitable for a 4KB DESfire EV1.

If you want to give it a spin, here’s the generator script, with the pre-built NDEF included: (20.0 KB)

I’m 99% certain that’s as far as this technology can be taken. Anything fancier requires a custom third-party helper app - which of course is not desirable if you want to share such a document seamlessly in mere seconds with anybody you happen by. So, it’s not perfect, but it has the merit of existing, it’s portable, and it works with a wide variety of stock cellphones.