Skip to content

Using Public Wi-Fi Safely

💡
Before you start

Python 3 and a terminal — nothing else, and nothing connects to the internet. macOS and Linux include Python; on Windows install it from python.org with “Add python.exe to PATH” ticked, then check with python3 --version.

Every packet below is built on your own machine and sent nowhere. The TLS handshake in steps 2 and 3 is genuine — produced by Python's own TLS library writing into a memory buffer instead of a network socket — so you are examining a real ClientHello, not a drawing of one. No network is joined, scanned or monitored at any point, which also means nothing here requires permission from anyone.

The Old Warning Is Mostly Obsolete

For years the standard advice was that anyone in the cafe could read your banking password out of the air. That was a fair description of the web around 2010, when most sites sent traffic unencrypted. It is no longer an accurate description of the web you use.

Well over ninety per cent of web traffic is now encrypted with HTTPS, and browsers actively warn you when a page is not. Someone sharing the network can no longer simply read the contents of your session. The US Federal Trade Commission updated its own public Wi-Fi guidance in 2026 to reflect exactly this: the situation has materially improved.

This matters because outdated fear produces bad decisions. People who believe public Wi-Fi is uniformly catastrophic burn mobile data unnecessarily, or buy a VPN expecting it to solve problems it does not address — while ignoring the risks that genuinely remain.

💡
Where the danger actually moved.

The remaining risks are not attacks on your encrypted traffic. They are attacks that happen before encryption begins, or that bypass it entirely: which network you joined, what a captive portal asks you to do, and what your device announces about itself.

What Is Still Genuinely Risky

  • Evil twin networks -- an attacker runs an access point named Airport_Free_WiFi or a duplicate of the cafe's real name. You join theirs, and they control the network you are on: DNS answers, what a portal shows you, and where unencrypted requests go. This is the single most credible attack
  • Malicious captive portals -- the sign-in page is attacker-controlled and asks for an email password, a card number "to verify your identity", or prompts you to install a certificate or profile. It looks like part of joining the network
  • Metadata exposure -- HTTPS hides page contents, not which sites you visit. The network operator can see the domains you connect to, and how often
  • Automatic reconnection -- your phone remembers network names and rejoins anything with a matching name, anywhere in the world, without asking
  • Device discovery -- on an open network your device may advertise shared folders, printers or media services to everyone else connected
  • Shoulder surfing -- unfashionable but effective. In an airport lounge, the person behind you is a more realistic threat than a packet capture

Habits That Actually Help

1
Ask staff for the exact network name.

Do not guess from the list, and be suspicious of two similarly named networks. If the venue has a password, that is better — a network with no password at all cannot distinguish itself from an impostor.

2
Turn off auto-join for public networks.

And periodically forget old ones. Your phone silently rejoining a name it learned in another city is how an evil twin catches you without any decision on your part.

3
Treat a captive portal as a stranger's website.

Give it a room number or a throwaway email if it insists. Never a password you use elsewhere, never card details, and never install a certificate or configuration profile it offers. Nothing legitimate about joining Wi-Fi requires that.

4
Never dismiss a certificate warning.

This is the one warning that still means what it always meant. On a hostile network it is the signal that something is intercepting your connection. Close the page — do not click through.

5
Prefer your phone's hotspot for anything sensitive.

Mobile data is a network you control the endpoints of. For banking or admin work, a tethered connection is simpler and stronger than any amount of care on someone else's Wi-Fi.

What a VPN Does and Does Not Do

A VPN is useful here, but it is routinely sold as a fix for things it cannot touch.

  • It does hide your browsing metadata from the network operator, so the cafe or the evil twin no longer sees which sites you visit
  • It does protect the minority of traffic still travelling unencrypted, and defeat DNS manipulation by a hostile network
  • It does not make an untrustworthy website safe, stop phishing, or prevent malware — those live above the tunnel
  • It does not remove trust; it moves it, from the cafe to the VPN provider. A free VPN that monetises your traffic is a downgrade, not an upgrade
  • It does not help if it silently drops. Check that the connection is actually up before doing anything sensitive
⚠️
Beware the false confidence.

"I have a VPN, so I am safe on public Wi-Fi" causes real harm. The dangerous moments — joining the wrong network, typing a password into a fake portal, clicking through a certificate warning — all happen either before the tunnel exists or entirely outside it.

Device Settings Worth Changing Once

  • Mark the network as Public when your device asks. On Windows this alone disables file and printer sharing and tightens the firewall
  • Turn off file and printer sharing, and any media server, before travelling
  • Enable your firewall -- on by default on modern systems; confirm it
  • Turn on private or randomised MAC addresses for public networks, so venues cannot trivially track your device across visits
  • Keep the OS and browser updated -- the certificate and HTTPS protections you are relying on are only as current as your software
  • Turn Wi-Fi off when you are not using it -- a radio that is not probing for remembered networks cannot be answered by an impostor

A Realistic Risk Ranking

Spend your caution where it earns something.

  • Reading news, maps, streaming -- fine anywhere. This is the overwhelming majority of what people do
  • Logging into ordinary accounts -- fine over HTTPS, and far safer if the account uses a passkey or app-based 2FA rather than SMS
  • Banking -- fine in practice, better on mobile data. Use the bank's app rather than a browser
  • Cryptocurrency, admin panels, or moving real money -- use your own hotspot. The cost of being wrong is too high to accept an unknown network
  • Any device you would not want compromised -- a work laptop with production access does not belong on a hotel network without the company's VPN

Watch Your Own Traffic Leave, in Five Steps

Public wi-fi advice has swung between two extremes for a decade: either every coffee-shop network is a trap that will empty your bank account, or encryption solved it years ago and there is nothing to think about. Both are wrong in interesting ways, and you can settle the question yourself in twenty minutes. You will build a plaintext request and read the password out of it, build a real TLS handshake and fail to find anything, then discover the two things that do escape — and finish knowing exactly what a VPN changes. Nothing below connects to the internet; every packet is constructed on your own machine. Every line of output came from running these files.

1
Read a password off the wire

Go: open a terminal in a folder you can write to — cd ~/Desktop on macOS or Linux, cd %USERPROFILE%\Desktop on Windows.

Do: save this as plaintext.py and run python3 plaintext.py.

"""What an unencrypted request looks like to anyone on the same network."""

request = (
    "POST /login HTTP/1.1\r\n"
    "Host: oldforum.example\r\n"
    "Content-Type: application/x-www-form-urlencoded\r\n"
    "Cookie: session=8f2a91c4b7\r\n"
    "\r\n"
    "username=sarah&password=correct-horse-battery\r\n"
).encode()

print("bytes on the wire:", len(request))
print("-" * 60)
print(request.decode().replace("\r\n", "\n").rstrip())
print("-" * 60)
print()
for label, needle in (("the site", b"Host: "), ("the password", b"password="),
                      ("the session cookie", b"Cookie: ")):
    print("%-20s readable in the clear: %s" % (label, needle in request))
print()
print("This is what http:// means. Not 'slightly less secure' -- the whole")
print("request, including what you typed, travels as the text above.")

You should see: the request exactly as anyone on the network would receive it:

bytes on the wire: 172
------------------------------------------------------------
POST /login HTTP/1.1
Host: oldforum.example
Content-Type: application/x-www-form-urlencoded
Cookie: session=8f2a91c4b7

username=sarah&password=correct-horse-battery
------------------------------------------------------------

the site             readable in the clear: True
the password         readable in the clear: True
the session cookie   readable in the clear: True

This is what http:// means. Not 'slightly less secure' -- the whole
request, including what you typed, travels as the text above.

This is what an http:// address means, and it is worth seeing rather than being told: the request is not obscured, encoded or scrambled — it is the text above, travelling as text. Anyone able to observe the network reads the site, the page, the cookie and the password with no effort and no tools worth the name.

The good news is that this is now rare. Browsers default to HTTPS, warn on password fields served over HTTP, and most sites redirect. The bad news is that “most” is not “all”, and the exceptions are exactly the old, forgotten sites where people reused a password years ago.

If not: python3: command not found on Windows means Python was installed without “Add python.exe to PATH”; try py plaintext.py. The byte count should be 172; a different number means a line ending was changed when copying, which is harmless here.

2
Build a real TLS handshake and try to find anything in it

Go: the same folder.

Do: save this as encrypted.py and run python3 encrypted.py. This uses Python's own TLS library with a memory buffer instead of a socket, so a genuine handshake message is produced without contacting anything.

"""The same request over TLS. Build a real handshake without connecting."""
import ssl

def client_hello(hostname):
    ctx = ssl.create_default_context()
    incoming, outgoing = ssl.MemoryBIO(), ssl.MemoryBIO()
    tls = ctx.wrap_bio(incoming, outgoing, server_hostname=hostname)
    try:
        tls.do_handshake()          # stops as soon as it needs a reply
    except ssl.SSLWantReadError:
        pass
    return outgoing.read()

hello = client_hello("www.example-bank.com")

print("ClientHello size          :", len(hello), "bytes")
print("record type               :", hex(hello[0]), "(0x16 = handshake)")
print("TLS version in the record :", "%d.%d" % (hello[1], hello[2]))
print()
secrets_after_handshake = [b"password=", b"Cookie:", b"/login"]
for needle in secrets_after_handshake:
    print("%-12s visible in the handshake: %s"
          % (needle.decode(), needle in hello))
print()
print("Nothing you type appears anywhere. The password, the page you asked")
print("for and the cookie are all sent AFTER the keys are agreed, and are")
print("encrypted with them.")

You should see: a real handshake record with none of your data in it:

ClientHello size          : 517 bytes
record type               : 0x16 (0x16 = handshake)
TLS version in the record : 3.1

password=    visible in the handshake: False
Cookie:      visible in the handshake: False
/login       visible in the handshake: False

Nothing you type appears anywhere. The password, the page you asked
for and the cookie are all sent AFTER the keys are agreed, and are
encrypted with them.

The record announces 3.1 for compatibility with old middleboxes — that is TLS 1.0's number, deliberately used in the outer record while the real version is negotiated inside; modern connections settle on TLS 1.3. What matters here is the three False results: the page requested, the cookie and the password do not exist yet at this point in the conversation, because they are sent only after the keys are agreed.

So the strong version of the warning is simply out of date. Somebody sharing a café network cannot read your banking session, and has not been able to for years.

If not: if ClientHello size prints 0, the SSLWantReadError was not caught — the handshake is expected to stop there, because there is no server to reply. The size will be close to 517 but may differ slightly with your Python version's cipher list, which is fine.

3
Find the thing that is not encrypted

Go: the same folder. This is the part the reassuring version leaves out.

Do: save this as sni.py and run python3 sni.py.

"""One thing does NOT get encrypted: the name of the site you asked for."""
import ssl

def client_hello(hostname):
    ctx = ssl.create_default_context()
    incoming, outgoing = ssl.MemoryBIO(), ssl.MemoryBIO()
    tls = ctx.wrap_bio(incoming, outgoing, server_hostname=hostname)
    try:
        tls.do_handshake()
    except ssl.SSLWantReadError:
        pass
    return outgoing.read()

def find_sni(hello):
    """Walk the printable runs and pick out anything shaped like a hostname."""
    runs, current = [], b""
    for b in hello:
        if 0x2D <= b <= 0x7A and chr(b) not in "\\^_`":
            current += bytes([b])
        else:
            if len(current) > 3:
                runs.append(current)
            current = b""
    return [r.decode() for r in runs if b"." in r]

for site in ("www.example-bank.com", "sensitive-medical-clinic.example"):
    hello = client_hello(site)
    print("you connect to :", site)
    print("   found in the handshake, unencrypted:", find_sni(hello))

print()
print("This field is called SNI. A server hosting many sites needs it to")
print("know which certificate to send -- so it must be readable before any")
print("encryption exists. The network you joined can read every one.")

You should see: the hostname sitting in the handshake as readable text:

you connect to : www.example-bank.com
   found in the handshake, unencrypted: ['www.example-bank.com']
you connect to : sensitive-medical-clinic.example
   found in the handshake, unencrypted: ['sensitive-medical-clinic.example']

This field is called SNI. A server hosting many sites needs it to
know which certificate to send -- so it must be readable before any
encryption exists. The network you joined can read every one.

The field is called Server Name Indication, and it has to be readable: one server address may host thousands of sites, so the server must be told which one you want before it can choose a certificate — and that is necessarily before any encryption exists.

The practical consequence is about the shape of the leak, not its severity. Whoever runs the network learns which sites you visit, and for the second example that is quite a lot. They do not learn which pages, what you typed, or what came back. There is a newer extension, Encrypted Client Hello, which closes this, but it is not yet universal.

If not: if the list prints extra entries alongside the hostname, that is expected — the function scans for printable runs containing a dot and may catch part of a cipher list. If it prints nothing, your Python was built with a TLS library that pads differently; the check that matters is whether the site name appears anywhere in the bytes.

4
Look at the question your device asks before any of that

Go: the same folder.

Do: save this as dnsleak.py and run python3 dnsleak.py.

"""Before any of that, your device asks a question out loud."""
import struct

def dns_query(hostname):
    """Build the packet a normal DNS lookup sends. No network involved."""
    header = struct.pack(">HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0)
    body = b""
    for label in hostname.split("."):
        body += bytes([len(label)]) + label.encode()
    body += b"\x00" + struct.pack(">HH", 1, 1)      # type A, class IN
    return header + body

for site in ("www.example-bank.com", "sensitive-medical-clinic.example"):
    packet = dns_query(site)
    readable = "".join(chr(b) if 32 <= b < 127 else "." for b in packet)
    print("%-34s %3d bytes" % (site, len(packet)))
    print("   on the wire: %s" % readable[12:])

print()
print("Sent to whichever DNS server the network told your device to use --")
print("which, on a network you just joined, is a server that network runs.")
print()
print("Encrypted DNS (DNS over HTTPS or DNS over TLS) closes this one.")
print("It does not close the SNI field from the previous step.")

You should see: the hostname readable inside the lookup packet:

www.example-bank.com                38 bytes
   on the wire: .www.example-bank.com.....
sensitive-medical-clinic.example    50 bytes
   on the wire: .sensitive-medical-clinic.example.....

Sent to whichever DNS server the network told your device to use --
which, on a network you just joined, is a server that network runs.

Encrypted DNS (DNS over HTTPS or DNS over TLS) closes this one.
It does not close the SNI field from the previous step.

Before your device can connect to anything it must turn a name into an address, and the ordinary way of doing that sends the name in clear text to whichever server the network nominated when you joined it. On a network you do not control, that server is theirs.

This one you can close, and it is worth doing. Turn on encrypted DNS: in Firefox it is Settings → Privacy & Security → DNS over HTTPS; in Chrome, Settings → Privacy and security → Security → Use secure DNS; on iOS and Android it is available system-wide as “Private DNS” or through a profile. It stops the lookup leaking — and, as the last line says, leaves the SNI field from step 3 exactly where it was.

If not: the printable rendering replaces the length bytes between labels with dots, so .www.example-bank.com..... is correct output rather than a formatting error — those dots are the label lengths and the trailing type and class fields.

5
Work out what a VPN actually changes

Go: the same folder.

Do: save this as vpn.py and run python3 vpn.py.

"""What a VPN moves, and what it does not remove."""

OBSERVERS = [
    ("the cafe's wi-fi router",     "sees encrypted traffic to one address", "yes"),
    ("anyone else on that wi-fi",   "sees encrypted traffic to one address", "yes"),
    ("the VPN provider",            "sees every site you visit",             "NEW"),
    ("your home internet provider", "sees nothing (you are not on it)",      "n/a"),
    ("the websites you visit",      "see the VPN's address, not yours",      "yes"),
]

print("%-30s %-42s %s" % ("WHO IS WATCHING", "WHAT THEY SEE WITH A VPN", "HELPED?"))
print("-" * 92)
for who, sees, helped in OBSERVERS:
    print("%-30s %-42s %s" % (who, sees, helped))

print()
print("observers a VPN removes :", sum(1 for _, _, h in OBSERVERS if h == "yes"))
print("observers a VPN adds    :", sum(1 for _, _, h in OBSERVERS if h == "NEW"))
print()
print("A VPN does not make traffic private. It changes WHO can see it --")
print("from the network you happened to join to a company you chose. That")
print("is usually a good trade, and it is a trade, not a cure.")

You should see: three observers removed and one added:

WHO IS WATCHING                WHAT THEY SEE WITH A VPN                   HELPED?
--------------------------------------------------------------------------------------------
the cafe's wi-fi router        sees encrypted traffic to one address      yes
anyone else on that wi-fi      sees encrypted traffic to one address      yes
the VPN provider               sees every site you visit                  NEW
your home internet provider    sees nothing (you are not on it)           n/a
the websites you visit         see the VPN's address, not yours           yes

observers a VPN removes : 3
observers a VPN adds    : 1

A VPN does not make traffic private. It changes WHO can see it --
from the network you happened to join to a company you chose. That
is usually a good trade, and it is a trade, not a cure.

A VPN wraps everything — including the SNI field and the DNS lookup — in a tunnel to a server you chose, so the network you joined sees only encrypted traffic to a single address. That genuinely closes steps 3 and 4 against the café. What it cannot do is remove the observer: it relocates them, from a network you happened to join to a company you are paying, which then sees everything the café would have.

Whether that is an improvement depends entirely on the provider, and the honest summary is that a VPN is worth having when you do not trust the local network and do trust the provider more. For a bank or a shop it changes little, because TLS was already doing the work. For keeping a network operator from learning which sites you visit, it is exactly the right tool.

The short version of this whole section: on an unknown network, HTTPS protects what you send; encrypted DNS and a VPN protect where you went; and the real risk is not eavesdropping at all but the network answering your requests with a page of its own — so treat any “sign in to continue” screen that appears because you joined as hostile, and never enter a password into it.

If not: the counts come from the table's last column, so editing a row's verdict changes them correctly; n/a is deliberately counted as neither.

🎉
Check yourself before moving on

Without scrolling up: a colleague says they never do anything sensitive on café wi-fi because someone could read their banking password, and that a VPN would fix it. Which part of their worry is out of date, which part is real, and what would you tell them to turn on? Answer: the worry about the password being read is out of date. Step 2 built a real TLS handshake and found nothing readable in it — the password, the page and the cookie are all sent after the keys are agreed, so somebody on the same network cannot recover them. What is real is the metadata: step 3 showed the site name travelling in clear text in the SNI field, and step 4 showed the DNS lookup doing the same, so the network operator learns which sites were visited even though the contents stay closed. The VPN does help with exactly that, by moving both inside a tunnel — but it substitutes the VPN provider for the café as the party who can see it, which is a trade rather than a fix. What to turn on is encrypted DNS, which is free and closes step 4 permanently on every network. And the risk actually worth warning them about is the one neither of them mentioned: a network that redirects them to a convincing login page of its own.

Now do it without the page: run sni.py with a hostname of your own choosing and confirm it appears in the handshake bytes on your machine too. Then check whether encrypted DNS is on in the browser you are reading this in — the settings paths are in step 4 — and turn it on if not. Finally, answer the question the code cannot: on the last unfamiliar network you joined, did a sign-in page appear, and did you type anything into it?

Summary

  • The classic eavesdropping threat is largely solved by near-universal HTTPS
  • Evil twins and captive portals are the real risks -- both attack the moment before encryption starts
  • Confirm the network name with staff, and turn off auto-join
  • Never install a profile or certificate a portal offers, and never click through a certificate warning
  • A VPN hides metadata and moves trust -- it is not a shield against phishing or malware
  • Use your own hotspot for the few things where being wrong is expensive
🎉
Proportion, not fear.

Public Wi-Fi is not the minefield it was described as a decade ago. Join the right network, be sceptical of anything the sign-in page asks for, keep auto-join off, and switch to mobile data for the handful of tasks that genuinely warrant it.