Mark Clowes (38M 🇬🇧)

Index - UK Passport ID Photo Codes


I renewed a UK Passport recently. It can all be completed online. You can take an ID photo that will go in the new passport yourself or use a photobooth, commonly seen in supermarkets. The photobooth will then give you a tiny URL and code that can be used during the passport application process to retrieve your digital photo directly. I was curious how this process worked.

The URL you can enter for the photo is restricted to 8 characaters for the domain and 3 for the TLD, and then 8 for the photo ID. This means a TLD cannot be entered. The photo I had was taken in a photobooth and provided the URL

My domain fits this criteria, so I setup a simple listener on to capture as much of the request as possible and asked the passport page to request a photo from my domain.

// Define the filename for logging
$logFile 'request_log.txt';

// Get request details
$requestDetails = array(
'Timestamp' => date('Y-m-d H:i:s'),
'Headers' => getallheaders(),
'User Agent' => $_SERVER['HTTP_USER_AGENT'],
'_GET' => $_GET,
'_POST' => $_POST,
'_FILES' => $_FILES,
'_SESSION' => isset($_SESSION) ? $_SESSION : array(),

// Convert request details to JSON format
$requestDetailsJSON json_encode($requestDetailsJSON_PRETTY_PRINT);

// Log request details to the file
file_put_contents($logFile$requestDetailsJSON PHP_EOLFILE_APPEND);


The relevant parts of the recorded data are that a request is sent from the passport service (from an AWS IP) with a useragent PhotoFetch and an Authorization header containing a JWT (JSON Web Token) that decodes to this simple payload:

  "iss": "HMPO",
  "iat": 1714299890204,
  "exp": 1714299920204,
  "sub": ""

This is a signed request. I have no idea where the public key is published or any guidance from the passport service on how to create a digital passport photo code service; presumably you have to ask. The payload contains only a little information; the request is from His Majesty's Passport Office, the URL that this request is for, and an "issued at" and "expiry" time (in Unix epoch milliseconds format). The JWT token is valid only for 30 seconds.

If you enter your photo code URL in your regular browser a 401 Unauthorized is returned with no content. When HMPO sends their valid signed JWT request to the URL it responds to them with a JPG. isnaps presumably checks that the token is signed by the correct public key, the request is occuring within the 30 second validity window of the token, and the URL of the request matches Otherwise someone naughty could replay the request. Best test these presumptions. Let's use curl to replay the request to isnaps. % curl -vL "" --header "Authorization: Bearer eyJhbGciOiJSUzI1Ni IsInR5cCI6IkpXVCJ9.eyJpc3MiOiJITVBPIiwiaWF0IjoxNzE0Mjk5ODkwMjA0LCJleHAiOjE3MTQyOTk5MjAyMDQsInN1YiI6Imh0dHBzOi8vY2xvdy5lcy9hYmNkMTIzNCJ9.OPkji4tnt7hkA 32fFPzyBxf3vVm_-Be2RUbVC1Cj-Su-f-Ug2jnmrpgm3ruKwuqjejiC_28sbBXpakZL3scLqpSs7J7xYS7Xeg6ZPBCQEQvpMIhq3yPNRrf6ZZOV_o1wNQMniNg2KpcsdL4dzNKH5dHFJp3v1FWT9k XTamI6JPtHb_JrelrjahsaZ_RFeVVd8GM-X4eKTVus3YoF7O_87OpK8psmZ90F-ByWNNq9tOpenF8hjcOxBhe9nPqov3FRCr-a1wkEgaWfmNaEYwxDXbHgqkKZQNoKb_VGBRsc2BGSCtFgzobY8Pm zQL5lCcHX_h7k8OJSWkNhYdC-xxu-OW549fp_SS1TWJzitexUx19q6KXGGaUchgYnH6X-xwlaq5XP19PpbMP1GFR5QZIfl40E4nouhtyRpsYC2XmY8hkC0PLS1VLY3rpL7yqjEkL_EFqmn1mN8wme bN04k2hHa696D29HNnESnFOp4jWIOVQaKKOrqbXxXP-Av3W55ZMbFwfSw3CF8K9MwlkG1XbVpdEbNdjqYDDaT-0CqXlY61rjlJj3xE1B4U1-kEdx5wh1I26a78Dq_Q8hj8SP6lRVX-XKFMsZFM4e5 5kKUSjUdzWaoFemIGvvh7b8IYHPwkU8An71mgHBltfjOaDSsTGBre8qcAyU7fVk9o6PMi9r22E" --output isnaps-test.jpg
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0*   Trying
* Connected to ( port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
} [5 bytes data]
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
} [512 bytes data]
* TLSv1.3 (IN), TLS handshake, Server hello (2):
{ [108 bytes data]
* TLSv1.2 (IN), TLS handshake, Certificate (11):
{ [3716 bytes data]
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
{ [333 bytes data]
* TLSv1.2 (IN), TLS handshake, Server finished (14):

{ [4 bytes data]
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
} [70 bytes data]
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
} [1 bytes data]
* TLSv1.2 (OUT), TLS handshake, Finished (20):
} [16 bytes data]
* TLSv1.2 (IN), TLS handshake, Finished (20):
{ [16 bytes data]
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
* ALPN, server accepted to use http/1.1
* Server certificate:
*  subject:
*  start date: Nov  6 00:00:00 2023 GMT
*  expire date: Nov  5 23:59:59 2024 GMT
*  subjectAltName: host "" matched cert's ""
*  issuer: C=US; O=DigiCert Inc;; CN=RapidSSL TLS RSA CA G1
*  SSL certificate verify ok.
} [5 bytes data]
> GET /25p37XiL HTTP/1.1
> Host:
> User-Agent: curl/7.68.0
> Accept: */*
> Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJITVBPIiwiaWF0IjoxNzE0Mjk5ODkwMjA0LCJleHAiOjE3MTQyOTk5MjAyMDQsInN1YiI6Imh0dHBz Oi8vY2xvdy5lcy9hYmNkMTIzNCJ9.OPkji4tnt7hkA32fFPzyBxf3vVm_-Be2RUbVC1Cj-Su-f-Ug2jnmrpgm3ruKwuqjejiC_28sbBXpakZL3scLqpSs7J7xYS7Xeg6ZPBCQEQvpMIhq3yPNRrf6 ZZOV_o1wNQMniNg2KpcsdL4dzNKH5dHFJp3v1FWT9kXTamI6JPtHb_JrelrjahsaZ_RFeVVd8GM-X4eKTVus3YoF7O_87OpK8psmZ90F-ByWNNq9tOpenF8hjcOxBhe9nPqov3FRCr-a1wkEgaWfm NaEYwxDXbHgqkKZQNoKb_VGBRsc2BGSCtFgzobY8PmzQL5lCcHX_h7k8OJSWkNhYdC-xxu-OW549fp_SS1TWJzitexUx19q6KXGGaUchgYnH6X-xwlaq5XP19PpbMP1GFR5QZIfl40E4nouhtyRps YC2XmY8hkC0PLS1VLY3rpL7yqjEkL_EFqmn1mN8wmebN04k2hHa696D29HNnESnFOp4jWIOVQaKKOrqbXxXP-Av3W55ZMbFwfSw3CF8K9MwlkG1XbVpdEbNdjqYDDaT-0CqXlY61rjlJj3xE1B4U1-kEdx5wh1I26a78Dq_Q8hj8SP6lRVX-XKFMsZFM4e55kKUSjUdzWaoFemIGvvh7b8IYHPwkU8An71mgHBltfjOaDSsTGBre8qcAyU7fVk9o6PMi9r22E
{ [5 bytes data]
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Date: Sun, 28 Apr 2024 20:43:50 GMT
< Server: Apache/2.4.25 (Debian)
< Vary: Authorization
< Content-Length: 407467
< Content-Type: image/jpeg
{ [5 bytes data]
100  397k  100  397k    0     0   830k      0 --:--:-- --:--:-- --:--:--  832k
* Connection #0 to host left intact % exiftool isnaps-test.jpg
ExifTool Version Number         : 11.88
File Name                       : isnaps-test.jpg
Directory                       : .
File Size                       : 398 kB
File Modification Date/Time     : 2024:04:28 22:43:50+02:00
File Access Date/Time           : 2024:04:28 22:43:50+02:00
File Inode Change Date/Time     : 2024:04:28 22:43:50+02:00
File Permissions                : rw-rw-r--
File Type                       : JPEG
File Type Extension             : jpg
MIME Type                       : image/jpeg
JFIF Version                    : 1.01
Resolution Unit                 : inches
X Resolution                    : 72
Y Resolution                    : 72
Image Width                     : 1550
Image Height                    : 1966
Encoding Process                : Baseline DCT, Huffman coding
Bits Per Sample                 : 8
Color Components                : 3
Y Cb Cr Sub Sampling            : YCbCr4:2:0 (2 2)
Image Size                      : 1550x1966
Megapixels                      : 3.0

Huh. Okay. A bit more experimentation showed that all isnaps is checking for is the validity of the JWT token signature. It is ignoring that the JWT is for a different URL and is outside of the 30 second validity window. The useragent is also not a factor.

Ultimately this is a mere curiosity. This does not gain you much or anything really. There is no interesting metadata in the JPG or within the response. The passport web page will also show you the image behind the photo code, albeit a thumbnail and cropped to the head/neck. Using curl you get the full resolution uncropped image. I'm going to assume being able to replay the request was an engineer just wanting it to work reliably and therefore not checking every part of the request they maybe should.