- Reference
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.
What SHA-256 does
Section titled “What SHA-256 does”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.comandPerson@example.comdiffer 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.
Normalize, then hash
Section titled “Normalize, then hash”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.
Python
Section titled “Python”import hashlibimport 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"))# dcc579fc5b2801b3232b625b5b2c0f81e55ff2ebce8e7275c1a0d147e7a4c58fhashlib is in the standard library, so there is nothing to install.
Node.js
Section titled “Node.js”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'));// dcc579fc5b2801b3232b625b5b2c0f81e55ff2ebce8e7275c1a0d147e7a4c58fPass '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.
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 "))// 542d240129883c019e106e3b1b2d3f3cb3537c43c425364de8e951d5a3083345Two 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.
<?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 '));// 542d240129883c019e106e3b1b2d3f3cb3537c43c425364de8e951d5a3083345hash() 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.
# macOS and Linuxprintf '%s' 'person@example.com' | shasum -a 256 | cut -d' ' -f1
# openssl, on any platform that has itprintf '%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:
echo 'person@example.com' | shasum -a 256# dbb1afe936121447c448b56d2711f367f37934d0f3eb418f406b36d8ccf6f100 <- wrongNothing 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:
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' ' -f1doneGoogle Sheets
Section titled “Google Sheets”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.
/** * 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.
| Dialect | Expression |
|---|---|
| PostgreSQL | encode(digest(lower(btrim(email)), 'sha256'), 'hex') |
| MySQL, MariaDB | SHA2(LOWER(TRIM(email)), 256) |
| BigQuery | TO_HEX(SHA256(LOWER(TRIM(email)))) |
| Snowflake | SHA2(LOWER(TRIM(email)), 256) |
| Redshift | SHA2(LOWER(BTRIM(email)), 256) |
| Databricks, Spark SQL | sha2(lower(trim(email)), 256) |
| SQL Server | LOWER(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:
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_hashedFROM crm.customersWHERE 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.
SQL Server needs two extra conversions
Section titled “SQL Server needs two extra conversions”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.
Hash one value without writing code
Section titled “Hash one value without writing code”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:
- CyberChef with the SHA2 operation, size 256.
- emn178 online tools.
Check your output
Section titled “Check your output”Run your implementation against these before you run it across a file. All three are lowercase hex, 64 characters.
| Input | SHA-256 |
|---|---|
person@example.com | 542d240129883c019e106e3b1b2d3f3cb3537c43c425364de8e951d5a3083345 |
+971567891234 | dcc579fc5b2801b3232b625b5b2c0f81e55ff2ebce8e7275c1a0d147e7a4c58f |
person@example.com\n | dbb1afe936121447c448b56d2711f367f37934d0f3eb418f406b36d8ccf6f100 |
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.
What breaks a hash
Section titled “What breaks a hash”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’snvarcharUTF-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.

