· Matthew Watt · One-offs · 7 min read
Securely save a SwiftUI view as a password-protected PDF
In order to develop the secure PDF seed phrase backup feature for Nighthawk Wallet 2.0, I needed to figure out how to render a SwiftUI view as a PDF and password protect it with a user-supplied password. In this guide, I share what I learned.
Overview
It seemed like a decently-sized effort, so I decided to break it down into the following steps that I would figure out one-by-one:
- Get password from user input
- Render the view to a PDF file sharable via a share sheet
- Password protect the PDF file
Our super secret content
Starting with a new empty iOS app project in Xcode, let’s create a view with some security-sensitive content:
struct SecretsView: View {
var body: some View {
VStack(spacing: 16) {
Text("KEEP SECRET:")
.font(.largeTitle)
Text("My phone number is (555) 555-5555")
Text("The one password I use for everything is M4tTiZcOoL123")
Text("I'm actually a really big Taylor Swift fan")
}
}
}
And our ContentView:
struct ContentView: View {
var body: some View {
VStack {
SecretsView()
}
.padding()
}
}
Which should look like this:
Get password from user input
Let’s add some state to our ContentView to hold the user’s password, a SecureField to allow the user to enter a password, and some extra spacing to give everything some room to breathe:
struct ContentView: View {
@State private var password = ""
var body: some View {
VStack(alignment: .center, spacing: 16) {
SecretsView()
SecureField("Password", text: $password)
.multilineTextAlignment(.center)
}
.padding()
}
}
We should now have:
Render the view to a PDF file sharable via a share sheet
For this next step, I started with a web search for “Save a SwiftUI view to a PDF”, which brought me to Paul Hudson’s Hacking with Swift article on the subject, which provided my starting point. Let’s add the following to ContentView:
func render() -> URL {
let renderer = ImageRenderer(content: SecretsView())
let url = URL.documentsDirectory.appending(path: "output.pdf")
renderer.render { size, context in
var box = CGRect(x: 0, y: 0, width: size.width, height: size.height)
guard let pdf = CGContext(url as CFURL, mediaBox: &box, nil) else {
return
}
pdf.beginPDFPage(nil)
context(pdf)
pdf.endPDFPage()
pdf.closePDF()
}
return url
}
And then update ContentView’s body like so:
var body: some View {
VStack(alignment: .center, spacing: 16) {
SecretsView()
SecureField("Password", text: $password)
.multilineTextAlignment(.center)
ShareLink(item: render()) {
Text("Securely save secrets")
}
}
.padding()
}
I made a few changes to suit our demo app’s needs, namely, I changed the output file name and I used a different version of ShareLink that takes content, since the one used in HWS includes a share icon, which I didn’t want. I strongly encourage you to read the original article, as it steps through what this code is doing in far greater detail.
But with that, we should have the following:
Firing up the simulator or a physical device, you should see the following:

Password protect the PDF file
The final step is to figure out how to apply the user’s entered password to the PDF itself. With the code we have now, we do nothing with the password. Let’s change that. In render, after the call to closePdf, add the following:
// Read as a PDF document and encrypt with the provided password
guard let pdfDocument = PDFDocument(url: url) else { return }
pdfDocument.write(
to: url,
withOptions: [
PDFDocumentWriteOption.userPasswordOption : password,
PDFDocumentWriteOption.ownerPasswordOption : password
]
)
We’ll also need to add the following import:
import PDFKit
This code leverages PDFKit to do the following:
- Read the PDF data saved at output.pdf into a PDFDocument object
- Write the PDF document back to output.pdf, using write options to pass the input password
And that’s really it. Altogether, our render function should now look like this:
func render() -> URL {
let renderer = ImageRenderer(content: SecretsView())
let url = URL.documentsDirectory.appending(path: "output.pdf")
renderer.render { size, context in
var box = CGRect(x: 0, y: 0, width: size.width, height: size.height)
guard let pdf = CGContext(url as CFURL, mediaBox: &box, nil) else {
return
}
pdf.beginPDFPage(nil)
context(pdf)
pdf.endPDFPage()
pdf.closePDF()
// Read as a PDF document and encrypt with the provided password
guard let pdfDocument = PDFDocument(url: url) else { return }
pdfDocument.write(
to: url,
withOptions: [
PDFDocumentWriteOption.userPasswordOption : password,
PDFDocumentWriteOption.ownerPasswordOption : password
]
)
}
return url
}
And firing it up in the simulator, if I enter the very secure password “password123” and tap share, I can see that I can save the file to disk and view it only by entering the password:

A problem lurking in the shadows
The eagle-eyed among you may have noticed an issue with the “final” code above. Before continuing, take a moment to see if you can figure it out. I’ll wait.
Ok, that’s enough time. Let’s dig into it.
A subtle security vulnerability
The issue lies with our render function. Take another look at it and I’ll give you one last chance to spot the bug:
func render() -> URL {
let renderer = ImageRenderer(content: SecretsView())
let url = URL.documentsDirectory.appending(path: "output.pdf")
renderer.render { size, context in
var box = CGRect(x: 0, y: 0, width: size.width, height: size.height)
guard let pdf = CGContext(url as CFURL, mediaBox: &box, nil) else {
return
}
pdf.beginPDFPage(nil)
context(pdf)
pdf.endPDFPage()
pdf.closePDF()
// Read as a PDF document and encrypt with the provided password
guard let pdfDocument = PDFDocument(url: url) else { return }
pdfDocument.write(
to: url,
withOptions: [
PDFDocumentWriteOption.userPasswordOption : password,
PDFDocumentWriteOption.ownerPasswordOption : password
]
)
}
return url
}
Spot it? It’s really subtle, so you’d be forgiven if you missed it (I did). The issue lies here:
guard let pdf = CGContext(url as CFURL, mediaBox: &box, nil) else {
return
}
Here, we initialize a CGContext to render the PDF into. The problem is the initializer. Let’s check out the documentation for it:

The key problem here is the URL parameter. When we render to a CGContext in this way, we are actually writing an unprotected version of our PDF to disk, after which we use PDFKit to overwrite that same file with the password protected version. In theory, an attacker could capture that unprotected file at some point between when we save it and overwrite it with the final version. Like I said, pretty subtle. Fortunately, the fix is pretty straightforward.
Patching the vulnerability
If the issue is that we are saving the PDF to a file and then overwriting that file, then it would seem that the solution is to write the PDF to some in-memory location, and then use PDFKit to securely save the password-protected version to disk. Handily, CoreGraphics provides an initializer for CGContext that allows us to do just that. Looking at the documentation, we find:

So instead of passing in a CFURL, it looks like we need to supply a CGDataConsumer. How do we do that? Well, since these are C APIs we’re working with, it can be a bit verbose, but it can be broken down as follows:
- Create a
CFMutableData
object that will be the buffer for the raw PDF bytes. - Create a
CGDataConsumer
, passing in theCFMutableData
object - Create our new
CGContext
, passing in theCGDataConsumer
object
Applying the fix
First, the CFMutableData
:
guard let pdfData = CFDataCreateMutable(nil, 0) else { return }
Then, the CGDataConsumer
:
guard let pdfDataConsumer = CGDataConsumer(data: pdfData),
Finally, the CGContext
:
let pdf = CGContext(consumer: pdfDataConsumer, mediaBox: &box, nil)
Put together, the final, patched version of the render function:
func render() -> URL {
let renderer = ImageRenderer(content: SecretsView())
let url = URL.documentsDirectory.appending(path: "output.pdf")
renderer.render { size, context in
guard let pdfData = CFDataCreateMutable(nil, 0) else { return }
var box = CGRect(x: 0, y: 0, width: size.width, height: size.height)
guard let pdfDataConsumer = CGDataConsumer(data: pdfData),
let pdf = CGContext(consumer: pdfDataConsumer, mediaBox: &box, nil) else {
return
}
pdf.beginPDFPage(nil)
context(pdf)
pdf.endPDFPage()
pdf.closePDF()
// Read as a PDF document and encrypt with the provided password
guard let pdfDocument = PDFDocument(url: url) else { return }
pdfDocument.write(
to: url,
withOptions: [
PDFDocumentWriteOption.userPasswordOption : password,
PDFDocumentWriteOption.ownerPasswordOption : password
]
)
}
return url
}
And that’s it! We have now securely saved a SwiftUI view to a password-protected PDF file, minimal heartache.