Compute PostgreSQL SCRAM-SHA-256 password verifiers in Zig, without sending the plaintext to the server.
  • Zig 94.7%
  • Nix 4.5%
  • Shell 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 30a43f69ad
All checks were successful
test / test (push) Successful in 6m34s
Build with Zig 0.17.0, and release 0.2.0
The toolchain is nixpkgs' Zig 0.17.0, which needed a newer nixpkgs in the
lock. zig-sasl moves to its 0.2.0 release and uucode to 1fb7343, the commits
that build with it; uucode's is the one zig-uri uses, so a project taking
both gets one copy. build.zig.zon.nix is regenerated with `zon2nix --17`.

Ports: `b.args` to `addPassthruArgs`, the enum builtins to
`@fromBackingInt` and `@backingInt`, `@typeInfo`'s enum fields to
`field_values`, and the `**` repetitions in the tests to @splat, since
0.17 dropped the operator.

Zig 0.16 users are pointed at the zig-0.16 branch and the v0.1.0 tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D72mPGShzbkY5JXHas6EHo
2026-10-08 23:02:06 -05:00
.forgejo/workflows Run the workflow on a NixOS runner, through the flake 2026-09-11 07:58:01 -05:00
completions Rename the binary to scram-sha-256 2026-08-27 20:19:49 -05:00
LICENSES License stringprep tables as MIT AND PostgreSQL 2026-08-25 15:53:18 -05:00
src Build with Zig 0.17.0, and release 0.2.0 2026-10-08 23:02:06 -05:00
.gitignore Build the CLI with Nix 2026-09-11 07:55:43 -05:00
build.zig Build with Zig 0.17.0, and release 0.2.0 2026-10-08 23:02:06 -05:00
build.zig.zon Build with Zig 0.17.0, and release 0.2.0 2026-10-08 23:02:06 -05:00
build.zig.zon.nix Build with Zig 0.17.0, and release 0.2.0 2026-10-08 23:02:06 -05:00
flake.lock Build with Zig 0.17.0, and release 0.2.0 2026-10-08 23:02:06 -05:00
flake.nix Build with Zig 0.17.0, and release 0.2.0 2026-10-08 23:02:06 -05:00
package.nix Build with Zig 0.17.0, and release 0.2.0 2026-10-08 23:02:06 -05:00
README.md Build with Zig 0.17.0, and release 0.2.0 2026-10-08 23:02:06 -05:00
REUSE.toml Build the CLI with Nix 2026-09-11 07:55:43 -05:00

zig-scram

SCRAM in Zig — RFC 5802 and RFC 7677: the four-message authentication exchange, channel binding, and the password verifiers it authenticates against, over any hash those RFCs name.

It is a library first, with a small CLI alongside it for the one job that is awkward to do any other way: computing a PostgreSQL verifier by hand.

scram.Client The client half of the exchange, as a state machine with no transport in it.
scram.Secret A verifier — what a server stores instead of a password.
scram.Mechanism(Hash) The same thing over some other hash; scram is Mechanism(Sha256).
scram.saslprep RFC 4013, which SCRAM runs over a password before hashing it.

A verifier is the half of SCRAM that PostgreSQL puts in front of its users:

SCRAM-SHA-256$<iterations>:<base64 salt>$<base64 StoredKey>:<base64 ServerKey>

That is the string it keeps in pg_authid.rolpassword. Computing it client-side means CREATE ROLE / ALTER ROLE can be issued with the verifier in place of the password, so the plaintext never crosses the wire, never lands in the server log, and never reaches pg_stat_activity.

ALTER ROLE alice PASSWORD 'SCRAM-SHA-256$4096:AAECAwQFBgcICQoLDA0ODw==$...';

Requires Zig 0.17.0; the zig-0.16 branch and the v0.1.0 tag build with Zig 0.16.0.

Install

zig fetch --save git+https://git.jcollie.dev/jeff/zig-scram.git

For Zig 0.16.0, take the zig-0.16 branch, which holds the last of zig-scram to build with it, or the v0.1.0 tag:

zig fetch --save git+https://git.jcollie.dev/jeff/zig-scram.git#zig-0.16
zig fetch --save git+https://git.jcollie.dev/jeff/zig-scram.git#v0.1.0
// build.zig
const scram = b.dependency("scram", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("scram", scram.module("scram"));

The only dependency is uucode, for the Unicode character data behind the SASLprep step. It is built with just the four fields this module reads.

Where this lives

The canonical repository is on my Forgejo instance:

git clone https://git.jcollie.dev/jeff/zig-scram.git

It is also published on Radicle, a peer-to-peer network where a repository has no canonical host — it lives on whichever nodes choose to seed it. Its Repository ID is:

rad:z3p1EVd76fZgybgAPzCpM25LAUCwP

With a Radicle node running (rad node start):

rad clone rad:z3p1EVd76fZgybgAPzCpM25LAUCwP

clone consults your node's routing table to find a seed holding the repository, so no host has to be named. The default branch is main, the same history you would get from the Forgejo instance. To help keep it available, seed it:

rad seed rad:z3p1EVd76fZgybgAPzCpM25LAUCwP

Zig's package manager does not speak rad://, so zig fetch still wants the git+https URL above. Radicle is for getting the source, filing issues, and sending patches without a forge account.

A mirror is kept on Tangled, where the repository lives at:

https://tangled.org/jcollie.dev/zig-scram

Verifiers

const std = @import("std");
const scram = @import("scram");

var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();

// Fresh random salt, PostgreSQL's own defaults: 16 salt bytes, 4096 rounds.
const secret = try scram.generate(gpa, threaded.io(), "hunter2", .{});

std.debug.print("{f}\n", .{secret});
// SCRAM-SHA-256$4096:6/+BSuGpL8lOjQ12JyH+JQ==$immDsh...:nWB1yw...

Secret is a plain value with no owned memory, so it can be copied, stored, and returned freely. Render it with the {f} placeholder, or:

var buf: [scram.max_encoded_length]u8 = undefined;
const text = try secret.bufPrint(&buf);      // no allocation

const owned = try secret.toOwnedString(gpa); // caller frees

Read one back, and check a password against it:

const stored = try scram.Secret.parse(rolpassword);

if (try stored.verify(gpa, attempt, .saslprep)) {
    // Recomputed with the stored salt and iteration count, compared in
    // constant time.
}

For a specific salt and round count — reproducing an existing verifier, or testing:

const secret = try scram.compute(gpa, password, salt, 4096, .saslprep);

generate takes Options:

field default
iterations 4096 PBKDF2 rounds. PostgreSQL 16+ exposes this as the scram_iterations GUC.
salt_length 16 Random salt bytes, up to max_salt_length (64).
normalization .saslprep See SASLprep.

The allocator is only touched when SASLprep has real work to do, which means never for an all-ASCII password and never for .raw.

A Secret holds no secret material: the plaintext cannot be recovered from it. It is still enough to impersonate the server to a client, though, so a verifier is sensitive even where it is not a password.

The text format is PostgreSQL's. The RFCs describe what a server has to know, not how to write it down, so while ScramSha1.Secret renders the same shape under a SCRAM-SHA-1$ tag, only the SHA-256 spelling is a string PostgreSQL will accept.

Authenticating

Client is the client half of the exchange, as a state machine with no transport in it: you hand it what arrived and send what it hands back. It never opens a socket and never touches TLS.

var client: scram.Client = try .init(gpa, io, .{
    .username = "user",
    .password = "pencil",
});
defer client.deinit();

try conn.send(client.clientFirst());
try client.handleServerFirst(try conn.receive());
try conn.send(try client.clientFinal());
try client.handleServerFinal(try conn.receive());

Both ends prove themselves. Returning from that last call is what authenticates the server — it has shown it knows ServerKey, which only the holder of the verifier does. A client that sends its proof and then treats the connection as good without getting there has proved itself to a stranger and learned nothing in return.

Every slice handed back is owned by the Client and lives until deinit; every slice passed in is copied if it is needed later, so your buffers are free immediately. The password is wiped as soon as the key schedule has run, and the keys as soon as the proofs are computed.

Client.Options:

field default
username — May be empty. PostgreSQL sends n=, because libpq has already named the user in the startup packet.
password —
authzid null The authorization identity, when it differs from the authentication one.
channel_binding .none See below.
normalization .saslprep Applied to the username and authzid as well as the password.
nonce_length 24 Random bytes to draw; the nonce is sent as base64 of them.
nonce null Use this nonce verbatim. Only for reproducing published vectors.
minimum_iterations 4096 Refuse a server that asks for fewer rounds than this, which RFC 7677 §4 makes the floor.

client.mechanism() gives the name to negotiate, and client.authenticated() answers whether the exchange completed.

Channel binding

Set channel_binding and the mechanism to negotiate becomes SCRAM-SHA-256-PLUS.

var client: scram.Client = try .init(gpa, io, .{
    .username = "user",
    .password = "pencil",
    .channel_binding = .{ .bound = .{
        .type = "tls-server-end-point",
        .data = cert_hash,          // you compute this from your TLS stack
    } },
});

The binding data is a property of the TLS connection, not of SCRAM, so it is passed in rather than derived here — which means tls-server-end-point, tls-exporter and tls-unique all work without this library depending on a TLS implementation.

The three variants are not preferences. They are assertions about what the server advertised, and the server checks them:

.none This client does not support channel binding.
.unsupported_by_server It does, but the server's mechanism list had no -PLUS variant. If that list was tampered with, the server knows what it really advertised and will abort.
.bound Bind the exchange to the transport.

Other hashes

RFC 5802 defines SCRAM generically and instantiates it once, as SCRAM-SHA-1; RFC 7677 instantiates it again as SCRAM-SHA-256, changing nothing but the hash. Mechanism(Hash) is that parameter made explicit, and scram is Mechanism(Sha256) under a shorter name.

const ScramSha1 = scram.ScramSha1;              // RFC 5802's own
const ScramSha512 = scram.Mechanism(std.crypto.hash.sha2.Sha512);

var client: ScramSha1.Client = try .init(gpa, io, .{ ... });

A hash with no registered mechanism name is a compile error rather than a guess, since the name is what the two ends use to agree on what they are running. SHA-1, SHA-224, SHA-256, SHA-384, SHA-512 and SHA3-512 are accepted; References cited says where each of those names comes from.

Errors

handleServerFirst rejects a server whose nonce does not extend the client's (error.NonceMismatch) and one asking for too few rounds (error.IterationCountTooLow). handleServerFinal returns error.ServerSignatureMismatch when the server cannot prove itself, and error.AuthenticationFailed when it answered e= — client.serverError() then says which of RFC 5802's error values it sent, for the log.

Message parsing follows the ABNF rather than accepting anything that could be understood: attributes must arrive in the order the grammar lists them, and an m= attribute fails the exchange wherever it appears, because it marks an extension the receiver is required to understand.

As a SASL mechanism

SCRAM is a SASL mechanism, and a protocol library should be able to run it without knowing what SCRAM is. src/sasl.zig is that adapter, exposed as a separate module, scram-sasl, so that scram itself keeps no dependency on zig-sasl and only something that wants to hand SCRAM to an SMTP, POP3 or IMAP client pays for it.

var inner: scram.Client = try .init(gpa, io, .{ .username = "user", .password = "pencil" });
defer inner.deinit();
var adapter: scram_sasl.Sha256 = .init(&inner, scram.ScramSha256.name);
try client.authenticate(adapter.client());   // the protocol library's call

The adapter's whole job is a mapping. SCRAM is four messages — client-first, server-first, client-final, server-final — and none of SMTP, POP3 or IMAP can carry data alongside a successful outcome, which RFC 4954 states outright and which IMAP's tagged OK does not change. So server-final arrives as one more challenge and is answered with an empty response before the protocol reports success.

That last exchange is where the server proves it knows ServerKey, so a client that treats the protocol's success as the end of the story has verified nothing. The adapter reports satisfied() == false until handleServerFinal has run, and a caller that checks it cannot make that mistake. The errors divide three ways, which is the distinction worth keeping: a signature that does not verify is BadServerProof — an accusation about the peer — where an e= is Rejected, a well-formed message from a server doing nothing wrong, and anything else is BadChallenge.

SASLprep

SCRAM does not hash the password bytes directly. It hashes SASLprep(password) — RFC 4013, a stringprep profile that maps some characters away, folds some to a space, normalizes to NFKC, and rejects the rest. Getting this wrong means the verifier silently disagrees with the server for any password outside ASCII.

There are two profiles, because the implementations that matter disagree with the RFC.

PostgreSQL's is the default, from its src/common/saslprep.c, because agreeing with that server is what this library is mostly for. It departs from RFC 4013 in four places:

  • All-ASCII passwords short-circuit. SASLprep is the identity on printable ASCII, so the whole profile is skipped. This is also why an ASCII control character in a password never trips the prohibited-output check, even though the table lists it.
  • The prohibit and bidi checks run before normalization, against the mapped string rather than the NFKC output, though RFC 3454 describes them as checks on the output. So U+0340, which is prohibited and which NFKC turns into the permitted U+0300, is refused.
  • A password that maps to nothing is refused, where the RFC gives the empty string.
  • Failure falls back to the raw bytes. If a password is not valid UTF-8, or contains a prohibited or Unicode-3.2-unassigned character, or breaks the bidirectional-text rules, both the server and libpq hash the unprepared bytes instead. .saslprep does the same.

RFC 4013's is the RFC as written, preparing a stored string, which is how RFC 5802 says SCRAM prepares a password. It checks the NFKC output, allows an empty result, and fails where preparation fails, as RFC 5802 requires of a client. It agrees with GNU libidn's SASLprep profile libidn on thousands of strings built from every class of character that matters here. Use it against a server that prepares strings by the RFC rather than as PostgreSQL does.

Both map U+200B, which RFC 3454 puts both in the table mapped to a space and in the table mapped to nothing, to a space, as PostgreSQL and libidn do.

Normalization picks between them, or neither:

  • .saslprep (default): PostgreSQL's profile, with its fallback. Reproduces the server's verifier for any password.
  • .rfc4013: RFC 4013's profile, failing with error.Unpreparable where it fails.
  • .raw: hash the bytes as given. Correct only if the caller has already prepared the password, or knows it is pure ASCII.

RFC 5802 also says a client should prepare the username, so Client runs the same profile over username and authzid.

To find out that a password needed PostgreSQL's fallback rather than silently taking it, call scram.saslprep.prep directly; it returns error.InvalidUtf8 or error.Prohibited instead. The CLI exposes this as --strict-prep. scram.saslprep.prepWith(gpa, password, .rfc4013) is the RFC profile on its own.

Unicode versions

The stringprep range tables are transcribed from PostgreSQL's saslprep.c, so the A.1 unassigned-code-point table stays frozen at Unicode 3.2 exactly as RFC 3454 specifies. Normalization, on the other hand, uses whatever Unicode version uucode ships — currently 17.0, against 15.1 in PostgreSQL 17 and 16.0 in PostgreSQL 18.

The RFC profile checks for unassigned code points before normalizing, where it checks everything else after. Under Unicode 3.2's NFKC, which RFC 3454 specifies, a character that 3.2 did not have passes through unchanged and is then found unassigned. A later NFKC can turn it into assigned characters, as it turns U+1F100 into "0.", so checking before normalizing is what gives 3.2's answer.

In practice this gap is unreachable: Unicode's normalization stability policy freezes a character's decomposition once assigned, so the versions can only disagree about characters that did not exist in the server's Unicode version.

The CLI

scram-sha-256 computes a PostgreSQL verifier and nothing else — it is named for the one mechanism whose stored format it writes, not for the library. It reads the password from stdin by default, since -p puts it in the process table where other users can see it.

$ zig build
$ printf 'hunter2' | ./zig-out/bin/scram-sha-256
SCRAM-SHA-256$4096:zLU9phQvqg5BXTM1oxGFsQ==$NokuUG1vCRqy...:l/jd28uhQujg...

$ ./zig-out/bin/scram-sha-256 --help

-i/--iterations, -s/--salt-length, --raw, and --strict-prep map onto the options above.

Shell completions

zig build install writes fish and bash completions under the prefix, in the directories both shells already search:

$ zig build install --prefix ~/.local
$ ls ~/.local/share/fish/vendor_completions.d/scram-sha-256.fish
$ ls ~/.local/share/bash-completion/completions/scram-sha-256

Both shells search $XDG_DATA_HOME (usually ~/.local/share) and every prefix on $XDG_DATA_DIRS, so a prefix already on those paths needs no further setup. bash also needs the bash-completion package, which loads the file on demand the first time scram-sha-256 is completed. Nothing searches zig-out, the default prefix, so either install to a real prefix or source the files from completions/ directly.

Nix

The flake builds the CLI and provides the development shell this project is worked on in:

nix build            # the CLI, with its shell completions, into ./result
nix run . -- --help
nix develop          # zig, reuse, zon2nix, kcov and perf

A Nix build has no network, and zig build wants one to fetch uucode. The bridge is build.zig.zon.nix, generated from build.zig.zon by zon2nix: it evaluates to a directory laid out like Zig's package cache, which package.nix hands to zig build --system, so Nix fetches the dependency and the build itself fetches nothing. Adding, removing or updating a dependency means regenerating it — never editing it — so that every hash comes from the manifest:

nix develop -c zon2nix --17 --nix=build.zig.zon.nix build.zig.zon

The dependency directory is also a flake output of its own, for running zig build against something other than the package:

zig build --system "$(nix build --print-out-paths .#zig-deps)"

Testing

zig build test

The suite replays both published transcripts end to end — RFC 7677 §3 for SCRAM-SHA-256 and RFC 5802 §5 for SCRAM-SHA-1 — checking every message the client emits byte for byte, including the proof and the verification of the server's signature. Between them they pin the whole derivation chain and the whole wire format, under two different hashes.

The proof is also checked the way a server checks it, from a stored Secret alone: recover ClientKey by undoing the XOR, hash it, and compare against StoredKey. That runs against a verifier derived independently of the code that built the proof.

The SASLprep half has been checked differentially against independent implementations. Those runs were one-off validations rather than part of the suite, since they need a Python interpreter and a copy of PostgreSQL's source:

  • NFKC against Python's unicodedata — 292,531 single code points and 155,704 random sequences, no mismatches. Code points unassigned in Python's Unicode 16 were skipped; the stability policy makes that sound.
  • The full SASLprep profile against a Python port of pg_saslprep, reading its tables straight out of saslprep.c rather than out of this repository — 331,229 random inputs, no mismatches.

Layout

file
src/scram.zig SCRAM-SHA-256: the Secret and Client everything else aliases.
src/mechanism.zig Mechanism(Hash) — the key schedule, the verifier, the client state machine.
src/messages.zig The RFC 5802 message grammar and the GS2 header.
src/saslprep.zig RFC 4013, following PostgreSQL's implementation.
src/nfkc.zig NFKC (UAX #15) over uucode's character data.
src/stringprep_tables.zig RFC 3454 range tables, transcribed from saslprep.c.
src/sasl.zig SCRAM as a zig-sasl mechanism, in its own scram-sasl module.
src/main.zig The CLI.
completions/ fish and bash completions for the CLI.

What is not here

The server half of the exchange. Secret holds exactly what a server needs and src/messages.zig has the grammar, so it is mostly message-writing, but nothing here accepts an exchange rather than initiating one.

References cited

Everything this implementation is answerable to. The note on each says what this project takes from it, not what the document is about.

The mechanism

RFC 5802 Salted Challenge Response Authentication Mechanism (SCRAM) SASL and GSS-API Mechanisms. The exchange, the message ABNF, the key schedule, the e= error values — and SCRAM-SHA-1, its own instantiation.
RFC 7677 SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. The default here, and where §4's floor of 4096 iterations comes from.
RFC 5801 GSS-API Mechanisms in SASL: The GS2 Mechanism Family. The gs2-header — the channel-binding flag and authorization identity that open the client's first message and reappear inside c=.
RFC 4422 Simple Authentication and Security Layer (SASL). The framework SCRAM is a mechanism of: what authentication and authorization identities are, and how a mechanism comes to be chosen.
draft-melnikov-scram-sha-512 SCRAM-SHA-512. An expired Internet-Draft.
draft-melnikov-scram-sha3-512 SCRAM-SHA3-512, likewise — and the one that has to exist, because SHA-3 has no entry in the hash-name registry below, so its mechanism name cannot be derived and must be specified outright.

Preparing passwords and usernames

RFC 4013 SASLprep: Stringprep Profile for User Names and Passwords. What src/saslprep.zig implements. Formally obsolete — see RFC 8265 below.
RFC 3454 Preparation of Internationalized Strings ("stringprep"). The framework SASLprep is a profile of, and the source of the range tables in src/stringprep_tables.zig.
UAX #15 Unicode Normalization Forms. NFKC, the normalization step.
RFC 3629 UTF-8. stringprep is defined over UTF-8, and a password that is not valid UTF-8 is one of the things that triggers the fallback to raw bytes.
libidn GNU Libidn. Its SASLprep profile, run as a stored string, is what the RFC 4013 profile was checked against.
RFC 8265 PRECIS Profiles for Usernames and Passwords. Obsoletes RFC 7613, which obsoleted RFC 4013 — so SASLprep has been superseded twice over. Listed because it is deliberately not implemented: RFC 7677 still specifies SASLprep and PostgreSQL still uses it, and a verifier that disagrees with the server is worthless however current its string preparation is.

Channel binding

RFC 5056 On the Use of Channel Bindings to Secure Channels. What a channel binding is and what attack it closes.
RFC 5929 Channel Bindings for TLS. tls-unique and tls-server-end-point.
RFC 9266 Channel Bindings for TLS 1.3. tls-exporter, which is the binding to use over TLS 1.3, where tls-unique is not defined.

Primitives

RFC 2104 HMAC.
RFC 8018 PKCS #5 v2.1 — PBKDF2, the salted iteration that turns a password into SaltedPassword.
FIPS 180-4 SHA-1 and the SHA-2 family.
FIPS 202 SHA-3.
RFC 4648 Base64, which every binary field on the wire and in a verifier is encoded with.

IANA registries

SASL Mechanisms Registered as the family SCRAM-*, so there is no individual SCRAM-SHA-256 row to look up.
Hash Function Textual Names sha-1, sha-224, sha-256, sha-384, sha-512, and nothing else. RFC 5802 §4 builds a mechanism name by prefixing one of these with SCRAM-, which is exactly the set Mechanism(Hash) accepts without a draft to point at.
Channel-Binding Types The cb-name values that may follow p=.

License

MIT, with one exception: src/stringprep_tables.zig is MIT AND PostgreSQL, because it is transcribed from PostgreSQL's src/common/saslprep.c — including that file's merging of adjacent ranges — and the PostgreSQL License requires its notice be retained. Both licenses are permissive and impose nothing beyond attribution.

The underlying tables are published in RFC 3454, whose copyright statement allows derivative works that "assist in its implementation ... without restriction of any kind", so no further grant is needed for them.

Every file carries an SPDX header and the license texts are in LICENSES/, so the project is REUSE compliant and reuse lint passes.

Note for anyone distributing a binary built from this: the Unicode character data reaches you through uucode, which ships the Unicode License alongside its own MIT license. Nothing to do when consuming this as source, but the Unicode License asks for its notice in distributions.