Skip to content

シークレットを一定時間で比較する snippet

シークレットを普通の等価比較にかけると、先頭から何バイト一致したかが漏れます: 文字列比較は最初の差分で即 return し、応答タイミングがそれを増幅してプレフィックスオラクルになります。修正方法は一定時間での比較 — 全部を走査し、最後に結論を出します。まともな stdlib なら必ず持っています (timingSafeEqual、hash_equals、crypt_memcmp、MessageDigest.isEqual、FixedTimeEquals)。手書きの XOR 畳み込みループは、事前に長さが一致している場合にのみ正しくなります — そして長さ自体もシークレットであってはなりません。

シークレットを普通の等価比較にかけると、先頭から何バイト一致したかが漏れます: 文字列比較は最初の差分で即 return し、応答タイミングがそれを増幅してプレフィックスオラクルになります。修正方法は一定時間での比較 — 全部を走査し、最後に結論を出します。まともな stdlib なら必ず持っています (timingSafeEqual、hash_equals、crypt_memcmp、MessageDigest.isEqual、FixedTimeEquals)。手書きの XOR 畳み込みループは、事前に長さが一致している場合にのみ正しくなります — そして長さ自体もシークレットであってはなりません。

Runnable recipe · 12 languages
Security Hardeningsecuritytiming-attackhmacwebhooksecrets

Every language

12 languages, copy-ready. One at a time with syntax highlighting, or all inline.

JSJavaScript
import crypto from 'node:crypto';

// webhook verify — hash to a FIXED 32 bytes, then compare:
function signatureValid(payload, receivedHex, secret) {
  const expected = crypto.createHmac('sha256', secret)
    .update(payload).digest();            // always 32 bytes
  const received = Buffer.from(receivedHex, 'hex');
  // timingSafeEqual THROWS on length mismatch — guard first:
  return expected.length === received.length &&
         crypto.timingSafeEqual(expected, received);
}

crypto.timingSafeEqual throws RangeError on unequal buffer lengths — the length guard (or hashing both sides to a fixed digest size, the webhook pattern) is not optional. Comparing the hex strings with === is the bug this replaces: it returns at the first differing character.

TSTypeScript
import { createHash, timingSafeEqual } from 'node:crypto';

// the wrapper every codebase should have exactly once:
export function fixedTimeEqual(a: Buffer, b: Buffer): boolean {
  return a.length === b.length && timingSafeEqual(a, b);
}

// strings arrive at arbitrary lengths — hash to 32 fixed bytes first:
export function tokensMatch(a: string, b: string): boolean {
  const sha256 = (s: string) => createHash('sha256').update(s).digest();
  return fixedTimeEqual(sha256(a), sha256(b));
}

The Buffer types make the byte-level contract explicit. The && ordering is load-bearing — timingSafeEqual throws (it does not silently return false) on unequal lengths, so the length check must come first; hashing normalizes length without exposing it.

GoGo
import (
	"crypto/hmac"
	"crypto/sha256"
	"crypto/subtle"
)

// raw primitive: returns 1 (equal) or 0 (differ) — an int, not a bool:
func fixedTimeEqual(a, b []byte) bool {
	return subtle.ConstantTimeCompare(a, b) == 1
}

// webhook shape: the MAC is a fixed 32 bytes — hash, then compare:
func signatureValid(payload, received, secret []byte) bool {
	mac := hmac.New(sha256.New, secret)
	mac.Write(payload)
	return hmac.Equal(mac.Sum(nil), received)
}

ConstantTimeCompare returns 0 immediately when lengths differ — safe because a digest's length is public; for MACs reach for hmac.Equal (the same primitive, named intent). subtle.ConstantTimeCopy copies conditionally — it is not a comparator; use it sparingly.

RsRust
use subtle::ConstantTimeEq;

fn fixed_time_equal(a: &[u8], b: &[u8]) -> bool {
    a.ct_eq(b).into() // Choice(1)/Choice(0) -> bool
}

// MAC check — the fixed 32-byte type makes equal lengths a compile-time fact:
fn mac_is_valid(computed: &[u8; 32], received: &[u8; 32]) -> bool {
    computed.ct_eq(received).into()
}

subtle is the constant-time substrate under all of RustCrypto — hmac, ed25519, and friends are built on it. ct_eq returns Choice, not bool, so .into() is required; unequal-length slices yield Choice(0), which is safe exactly as long as lengths are public.

PHPPHP
// hash_equals(known_string, user_string) — the stored secret goes FIRST:
$valid = hash_equals($storedToken, $tokenFromRequest);

// signatures arrive hex-encoded at unknown length — hash to fixed size:
$valid = hash_equals(
    hash_hmac('sha256', $payload, $secret),
    $receivedHex
);

Argument order is (known, user) — swapping them still returns the right answer but reads backwards in every audit. PHP strings carry their length, so length is never secret in PHP-land and hash_equals handles the rest internally (constant time since PHP 5.6). === on MACs is the leak.

PyPython
import hmac

# compare_digest lives in hmac (hashlib re-exports it); str inputs must be ASCII:
valid = hmac.compare_digest(expected_hex, received_hex)

# webhook shape — HMAC to a fixed hex length, then compare:
digest = hmac.new(secret, payload, 'sha256').hexdigest()
valid = hmac.compare_digest(digest, signature_header)

compare_digest accepts str or bytes — str inputs must be ASCII-only or it raises TypeError — and its arguments are order-free. a == b on MACs is the leak it replaces: CPython's == returns at the first differing byte.

C#C#
using System.Security.Cryptography;

static bool FixedTimeEqual(byte[] a, byte[] b)
{
    return a.Length == b.Length
        && CryptographicOperations.FixedTimeEquals(a, b);
}

CryptographicOperations.FixedTimeEquals (.NET Core 2.1+) already returns false on unequal lengths — the guard exists for clarity, not safety. LINQ's SequenceEqual and string == short-circuit and leak; before 2.1 people abused MachineKey.Equals for this job.

JvJava
import java.security.MessageDigest;

static boolean signatureValid(byte[] expected, byte[] received) {
    return MessageDigest.isEqual(expected, received);
}

MessageDigest.isEqual has been constant-time since Java 6u17 and tolerates unequal lengths — it is the one JDK comparator that is safe for secrets. Arrays.equals and String.equals return at the first mismatch and leak the prefix.

SwSwift
import CryptoKit

// Swift has NO stdlib constant-time compare — the honest fallback:
func fixedTimeEqual(_ a: [UInt8], _ b: [UInt8]) -> Bool {
    guard a.count == b.count else { return false }  // length is public
    var diff: UInt8 = 0
    for i in a.indices {
        diff |= a[i] ^ b[i]   // fold ALL bytes, decide once at the end
    }
    return diff == 0
}

// the MAC use-case — Apple ships the real thing:
let ok = HMAC<SHA256>.isValidAuthenticationCode(
    receivedMAC, authenticating: payload, using: key)

Swift's stdlib has no constant-time compare — == on Data/Array short-circuits. The XOR-fold over every byte with a length guard is the honest fallback; for MACs prefer CryptoKit's HMAC.isValidAuthenticationCode (swift-crypto brings it to Linux). When a library primitive exists, prefer it — hand-rolled folds depend on the optimizer staying honest.

KtKotlin
import java.security.MessageDigest
import java.util.HexFormat

fun signatureValid(expectedHex: String, receivedHex: String): Boolean =
    MessageDigest.isEqual(
        HexFormat.of().parseHex(expectedHex),  // hex string -> bytes (JDK 17+)
        HexFormat.of().parseHex(receivedHex),
    )

Same JVM primitive as Java — MessageDigest.isEqual. Hex strings must be parsed to bytes before comparing: == on the hex strings themselves leaks the prefix. HexFormat.of().parseHex (JDK 17+) replaces the old clunky parse loops.

RbRuby
require 'openssl'

valid = OpenSSL.secure_compare(expected, received)

# Rails ships the same primitive under a longer name:
# require 'active_support/security_utils'
# valid = ActiveSupport::SecurityUtils.secure_compare(expected, received)

OpenSSL.secure_compare SHA-256-hashes both sides to a fixed size before comparing — the digest-then-compare pattern, built in. Rails re-exposes it as ActiveSupport::SecurityUtils.secure_compare; plain == on secrets short-circuits at the first differing byte.

ZigZig
const std = @import("std");
const HmacSha256 = std.crypto.auth.hmac.sha2.HmacSha256;

fn signatureValid(payload: []const u8, received: [32]u8, key: []const u8) bool {
    var mac: [HmacSha256.mac_length]u8 = undefined; // fixed 32 bytes
    HmacSha256.create(&mac, payload, key);
    return std.crypto.timing_safe.eql([32]u8, mac, received);
}

std.crypto.timing_safe.eql takes two values of the SAME fixed-size array type — equal lengths are enforced by the type system, which is exactly why you HMAC both sides to the fixed mac_length first. std.crypto.utils.secureZero wipes keys; it is not a comparator.