Skip to content

Apply SHA-256 hashing to PII

This page shows how to produce a SHA-256 hash of a personal identifier in the languages and tools most teams already use. It covers the mechanics only.

What each field has to look like before you hash it is a separate question, and it is the one that decides whether the hash matches anything. Identifier normalization & hashing holds those rules. Read it first, then come back here for the code.

SHA-256 turns any input into a fixed 256-bit value, written as 64 hexadecimal characters. Signals writes it in lowercase.

Three properties matter for identity matching:

  • It is one way. The output cannot be reversed to recover the email address that produced it. This is what makes it safe to send a hashed identifier to an advertising platform.
  • It is deterministic. The same input always produces the same output, on any machine, in any language, forever. That is what lets you and the destination arrive at the same value independently, without either side seeing the other’s raw data.
  • It has no tolerance. person@example.com and Person@example.com differ by one bit of input and produce two hashes with nothing in common. There is no “close enough”.

The third property is why normalization is not optional. Both sides must feed the algorithm byte-identical input or the match fails, and nothing on either side can detect that it happened.

Hashing is not encryption. There is no key and no decryption step, so do not describe it as encryption in a security review or a customer conversation.

Every snippet below follows the same two steps: normalize the value to the rule for its field, then hash the normalized string as UTF-8.

The examples use two fields, an email address and a phone number, because those two carry most of the match rate. Apply the same shape to the other fields using the rules in the normalization reference.

The phone helpers assume a national-format number and add the country code you pass in. If your data already carries country codes on some rows, detect that before prefixing, or you will produce numbers like +971971567891234.

hashing.py
import hashlib
import re
def sha256_hex(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def normalize_email(value: str) -> str:
return value.strip().lower()
def normalize_phone(value: str, country_code: str) -> str:
digits = re.sub(r"\D", "", value).lstrip("0")
return "+" + country_code + digits
sha256_hex(normalize_email(" Person@Example.com "))
# 542d240129883c019e106e3b1b2d3f3cb3537c43c425364de8e951d5a3083345
sha256_hex(normalize_phone("056 789 1234", "971"))
# dcc579fc5b2801b3232b625b5b2c0f81e55ff2ebce8e7275c1a0d147e7a4c58f

hashlib is in the standard library, so there is nothing to install.

hashing.mjs
import { createHash } from 'node:crypto';
const sha256Hex = (value) => createHash('sha256').update(value, 'utf8').digest('hex');
const normalizeEmail = (value) => value.trim().toLowerCase();
const normalizePhone = (value, countryCode) =>
'+' + countryCode + value.replace(/\D/g, '').replace(/^0+/, '');
sha256Hex(normalizeEmail(' Person@Example.com '));
// 542d240129883c019e106e3b1b2d3f3cb3537c43c425364de8e951d5a3083345
sha256Hex(normalizePhone('056 789 1234', '971'));
// dcc579fc5b2801b3232b625b5b2c0f81e55ff2ebce8e7275c1a0d147e7a4c58f

Pass 'utf8' to update(). Without it Node treats a string as UTF-8 anyway, but being explicit keeps the snippet correct if someone later passes a buffer.

In a browser, node:crypto is not available. Use the Web Crypto API instead, shown under Hash one value without writing code.

Hashing.java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class Hashing {
public static String sha256Hex(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(64);
for (byte b : bytes) {
hex.append(String.format("%02x", b));
}
return hex.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is required on every JVM", e);
}
}
public static String normalizeEmail(String value) {
return value.trim().toLowerCase();
}
public static String normalizePhone(String value, String countryCode) {
String digits = value.replaceAll("\\D", "").replaceAll("^0+", "");
return "+" + countryCode + digits;
}
}
// sha256Hex(normalizeEmail(" Person@Example.com "))
// 542d240129883c019e106e3b1b2d3f3cb3537c43c425364de8e951d5a3083345

Two details in that snippet are load-bearing. getBytes(StandardCharsets.UTF_8) pins the encoding: the no-argument getBytes() uses the platform default charset, so the same code produces different hashes on different machines for any name with an accent. And %02x pads single-digit bytes, without which roughly half of all hashes come out shorter than 64 characters.

MessageDigest is not thread safe. Create one per call, or hold one per thread.

hashing.php
<?php
function sha256_hex(string $value): string {
return hash('sha256', $value);
}
function normalize_email(string $value): string {
return strtolower(trim($value));
}
function normalize_phone(string $value, string $countryCode): string {
$digits = ltrim(preg_replace('/\D/', '', $value), '0');
return '+' . $countryCode . $digits;
}
sha256_hex(normalize_email(' Person@Example.com '));
// 542d240129883c019e106e3b1b2d3f3cb3537c43c425364de8e951d5a3083345

hash() returns lowercase hex already. Use mb_strtolower($value, 'UTF-8') in place of strtolower() if your data contains non-ASCII names, because strtolower() only lowercases ASCII and leaves accented capitals untouched.

Terminal window
# macOS and Linux
printf '%s' 'person@example.com' | shasum -a 256 | cut -d' ' -f1
# openssl, on any platform that has it
printf '%s' 'person@example.com' | openssl dgst -sha256 | awk '{print $NF}'

Both print 542d240129883c019e106e3b1b2d3f3cb3537c43c425364de8e951d5a3083345.

Use printf '%s', not echo. echo appends a newline, that newline is part of the input, and the hash changes completely:

Terminal window
echo 'person@example.com' | shasum -a 256
# dbb1afe936121447c448b56d2711f367f37934d0f3eb418f406b36d8ccf6f100 <- wrong

Nothing warns you. The value is a valid 64-character hash of a string nobody else will ever produce. This is the most common way a hand-checked value disagrees with an export that was actually correct.

To hash a column of a CSV, one hash per line:

Terminal window
tail -n +2 contacts.csv | cut -d, -f1 | tr 'A-Z' 'a-z' | while read -r v; do
printf '%s' "$v" | shasum -a 256 | cut -d' ' -f1
done

Sheets has no built-in SHA-256 function. SHA256() does not exist, and neither does anything equivalent in the formula language, so this needs a custom function in Apps Script.

Open Extensions > Apps Script, paste the following, save, then return to the sheet.

Code.gs
/**
* SHA-256 of a value, as 64 lowercase hex characters.
* @param {string} input The value to hash.
* @return {string} The hash, or an empty string for an empty input.
* @customfunction
*/
function SHA256(input) {
if (input === null || input === undefined || input === '') return '';
var bytes = Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
String(input),
Utilities.Charset.UTF_8
);
return bytes
.map(function (b) {
return ((b < 0 ? b + 256 : b).toString(16)).padStart(2, '0');
})
.join('');
}

Then in the sheet, normalizing in the formula and hashing the result:

=SHA256(LOWER(TRIM(A2)))

Two things in that function exist for a reason, and both produce a wrong answer rather than an error if you drop them.

Utilities.computeDigest returns signed bytes, in the range -128 to 127, not the 0 to 255 you might expect. Calling .toString(16) on a negative byte yields something like -4d, so the naive one-line version produces a string with minus signs in it that is neither 64 characters nor a valid hash. Eight of the 32 bytes in the example hash above are negative, so the corruption is not rare. (b < 0 ? b + 256 : b) is the fix.

padStart(2, '0') covers bytes below 16, which render as a single hex digit. Without it the output is short by one character per such byte.

The guard on empty input returns an empty string rather than the hash of "". See What breaks a hash for why that matters.

Sheets recalculates custom functions on open and on edit, and each call is comparatively slow. On more than a few thousand rows, hash in the source system or paste the results back as static values with Edit > Paste special > Values only.

Hash in the query when your source is a database or warehouse. The value is then already hashed by the time it leaves your infrastructure.

DialectExpression
PostgreSQLencode(digest(lower(btrim(email)), 'sha256'), 'hex')
MySQL, MariaDBSHA2(LOWER(TRIM(email)), 256)
BigQueryTO_HEX(SHA256(LOWER(TRIM(email))))
SnowflakeSHA2(LOWER(TRIM(email)), 256)
RedshiftSHA2(LOWER(BTRIM(email)), 256)
Databricks, Spark SQLsha2(lower(trim(email)), 256)
SQL ServerLOWER(CONVERT(varchar(64), HASHBYTES('SHA2_256', CONVERT(varchar(320), LOWER(LTRIM(RTRIM(email))))), 2))

PostgreSQL needs the pgcrypto extension for digest(). Install it once per database:

CREATE EXTENSION IF NOT EXISTS pgcrypto;

A fuller example, hashing email and phone together:

BigQuery
SELECT
order_id,
TO_HEX(SHA256(LOWER(TRIM(email)))) AS email_hashed,
TO_HEX(SHA256(CONCAT('+', country_code, REGEXP_REPLACE(phone, r'\D', '')))) AS phone_hashed
FROM crm.customers
WHERE email IS NOT NULL AND TRIM(email) != ''

Keep the WHERE clause, or its equivalent for your dialect. Without it, rows with no email produce a hash of the empty string.

HASHBYTES is the one function on that list that is wrong by default, in two independent ways.

It hashes the raw bytes of whatever you hand it. Pass an nvarchar and it hashes UTF-16, two bytes per ASCII character, which produces a completely different value from the UTF-8 hash every other system on this page produces. The CONVERT(varchar(320), ...) is what forces single-byte encoding.

It also returns uppercase hex under CONVERT style 2, and Signals and the destinations expect lowercase. Hence the outer LOWER().

For non-ASCII data on SQL Server 2019 or later, convert under a UTF-8 collation so accented characters survive the conversion rather than degrading to ?:

LOWER(CONVERT(varchar(64), HASHBYTES('SHA2_256',
CONVERT(varchar(320), LOWER(LTRIM(RTRIM(email)))) COLLATE Latin1_General_100_CI_AS_SC_UTF8
), 2))

On SQL Server 2016 and earlier, HASHBYTES also truncates input above 8000 bytes. No contact field comes close, so this only matters if you hash concatenated values.

For spot-checking a single value, in rough order of preference:

A terminal. The shasum and openssl one-liners above need nothing installed and the value never leaves your machine.

Browser devtools. Open the console on any page and paste:

const sha256 = async (value) =>
[...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)))]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
await sha256('person@example.com');
// '542d240129883c019e106e3b1b2d3f3cb3537c43c425364de8e951d5a3083345'

crypto.subtle is only available on HTTPS pages and on localhost.

A client-side web tool. Both of these compute the hash in your browser with no network request:

Run your implementation against these before you run it across a file. All three are lowercase hex, 64 characters.

InputSHA-256
person@example.com542d240129883c019e106e3b1b2d3f3cb3537c43c425364de8e951d5a3083345
+971567891234dcc579fc5b2801b3232b625b5b2c0f81e55ff2ebce8e7275c1a0d147e7a4c58f
person@example.com\ndbb1afe936121447c448b56d2711f367f37934d0f3eb418f406b36d8ccf6f100

The third row is the trailing-newline case. If your output matches it rather than the first row, something in your pipeline is appending a newline.

Then check the file itself:

  • Every value is 64 characters. Anything else is not a SHA-256 hash.
  • Every value is lowercase. Uppercase hex is the SQL Server default and will not match.
  • No value equals e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. That is the hash of the empty string, and one row carrying it means one blank got hashed.
  • The column has close to as many distinct values as it has rows. A repeated hash means a repeated input, usually a default or placeholder value.

Each of these produces a valid-looking 64-character hash that matches nothing. None of them raise an error, in your code or at the destination, so the only symptom is a match rate that is lower than it should be.

  • A trailing newline or a stray space. From echo, from a CSV reader that keeps line endings, or from a value that was never trimmed.
  • The wrong encoding. Java’s default-charset getBytes(), or SQL Server’s nvarchar UTF-16. Both are correct SHA-256 of the wrong bytes.
  • Uppercase hex. Correct hash, wrong case.
  • Hashed blanks. A hash of "" is a real hash. Leave the field empty instead.
  • Double hashing. Hashing a column that was already hashed upstream. A 64-character lowercase hex input to a hash function is not detectable as already-hashed, so check what the source system sends before adding a hashing step.
  • Hashing a field that must stay plain. Click IDs, cookies, device advertising identifiers, and the location fields when the destination is Google. Identifier normalization & hashing lists all of them.
  • Skipping normalization. The failure this whole page exists to prevent. A hash of an un-normalized value is indistinguishable from a hash of a normalized one.