Skip to content

JWT Debugger — Zig source

Decode a JSON Web Token and inspect its header and payload as pretty JSON, with exp/iat claim awareness and an expiry badge. Runs entirely in your browser.

This is the Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! JWT base64url decoding helper — Zig port of the jwt tool.

const std = @import("std");

pub const B64Error = error{ InvalidCharacter, InvalidLength, OutOfMemory };

fn indexOf(c: u8) ?u8 {
    return switch (c) {
        'A'...'Z' => c - 'A',
        'a'...'z' => c - 'a' + 26,
        '0'...'9' => c - '0' + 52,
        '+' => 62,
        '/' => 63,
        else => null, // '=' is only legal as the final 1-2 chars
    };
}

/// Decode a base64url segment (JWT header/payload) into bytes. Zig's stdlib
/// has url-safe decoders, but the canonical steps are spelled out here so
/// they stay visible. The returned slice is byte-faithful (the caller reads
/// it Latin-1, like JS atob); errors play the role of the TS throw.
pub fn b64urlDecode(alloc: std.mem.Allocator, str: []const u8) B64Error![]u8 {
    // 1. URL-safe alphabet -> standard base64 alphabet.
    const mapped = try alloc.alloc(u8, str.len);
    defer alloc.free(mapped);
    for (str, 0..) |c, i| {
        mapped[i] = switch (c) {
            '-' => '+',
            '_' => '/',
            else => c,
        };
    }

    // 2. Restore stripped '=' padding so the length is a multiple of 4.
    if (mapped.len % 4 == 1) return error.InvalidLength;
    const padded_len = mapped.len + (4 - mapped.len % 4) % 4;
    const buf = try alloc.alloc(u8, padded_len);
    defer alloc.free(buf);
    @memcpy(buf[0..mapped.len], mapped);
    @memset(buf[mapped.len..], '=');

    // 3. Strict decode: 4-char blocks -> 3 bytes, validating every char and
    //    every '=' (legal only as the final 1-2 chars of the final block).
    const out = try alloc.alloc(u8, padded_len / 4 * 3);
    errdefer alloc.free(out);
    var o: usize = 0;
    var q: usize = 0;
    while (q < padded_len) : (q += 4) {
        var v: [4]u8 = .{ 0, 0, 0, 0 };
        var pad: usize = 0;
        for (0..4) |k| {
            const c = buf[q + k];
            if (c == '=') {
                pad += 1;
                continue;
            }
            if (pad != 0) return error.InvalidCharacter; // data after '='
            v[k] = indexOf(c) orelse return error.InvalidCharacter;
        }
        if (pad > 2 or (pad != 0 and q + 4 != padded_len)) return error.InvalidCharacter;
        const triple: u32 = (@as(u32, v[0]) << 18) | (@as(u32, v[1]) << 12) |
            (@as(u32, v[2]) << 6) | @as(u32, v[3]);
        out[o] = @truncate(triple >> 16);
        if (pad < 2) out[o + 1] = @truncate(triple >> 8);
        if (pad < 1) out[o + 2] = @truncate(triple);
        o += 3 - pad; // a valid block emits 3, 2 or 1 bytes (pad = 0, 1, 2)
    }

    // 4. Shrink to the decoded size; the bytes are the result.
    return alloc.realloc(out, o);
}

pub fn main() !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    // eyJhbGciOiJIUzI1NiJ9 decodes to {"alg":"HS256"}
    const text = try b64urlDecode(arena.allocator(), "eyJhbGciOiJIUzI1NiJ9");
    std.debug.print("{s}\n", .{text});
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →