use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub user_id: u64,
pub issued_at: u64,
pub expires_at: u64,
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock before epoch")
.as_secs()
}
impl Session {
pub fn new(user_id: u64, ttl_secs: u64) -> Self {
let issued_at = now();
Session {
user_id,
issued_at,
expires_at: issued_at + ttl_secs,
}
}
pub fn is_expired(&self) -> bool {
now() >= self.expires_at
}
}
use crate::session::Session;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, PartialEq)]
pub enum CookieError {
BadFormat,
BadSignature,
Expired,
}
pub struct CookieCodec {
key: Vec<u8>,
}
impl CookieCodec {
pub fn new(key: &[u8]) -> Self {
CookieCodec { key: key.to_vec() }
}
fn mac(&self, payload: &[u8]) -> HmacSha256 {
let mut m = HmacSha256::new_from_slice(&self.key).expect("hmac accepts any key length");
m.update(payload);
m
}
pub fn encode(&self, session: &Session) -> String {
let payload = serde_json::to_vec(session).expect("session serializes");
let tag = self.mac(&payload).finalize().into_bytes();
format!("{}.{}", B64.encode(&payload), B64.encode(tag))
}
pub fn decode(&self, cookie: &str) -> Result<Session, CookieError> {
let (p_b64, sig_b64) = cookie.split_once('.').ok_or(CookieError::BadFormat)?;
let payload = B64.decode(p_b64).map_err(|_| CookieError::BadFormat)?;
let sig = B64.decode(sig_b64).map_err(|_| CookieError::BadFormat)?;
// constant-time verification; never compare tags with ==
self.mac(&payload)
.verify_slice(&sig)
.map_err(|_| CookieError::BadSignature)?;
let session: Session =
serde_json::from_slice(&payload).map_err(|_| CookieError::BadFormat)?;
if session.is_expired() {
return Err(CookieError::Expired);
}
Ok(session)
}
}
mod cookie_codec;
mod session;
use cookie_codec::{CookieCodec, CookieError};
use session::Session;
fn main() {
let secret = b"rotate-me-and-load-from-env-not-source";
let codec = CookieCodec::new(secret);
let session = Session::new(42, 3600);
let cookie = codec.encode(&session);
println!("Set-Cookie: sid={cookie}; HttpOnly; Secure; SameSite=Lax");
match codec.decode(&cookie) {
Ok(s) => println!("authenticated user_id={}", s.user_id),
Err(e) => println!("rejected: {:?}", e),
}
let (payload, sig) = cookie.split_once('.').unwrap();
let mut bytes = payload.as_bytes().to_vec();
bytes[0] ^= 0x01;
let tampered = format!("{}.{}", String::from_utf8_lossy(&bytes), sig);
assert_eq!(codec.decode(&tampered), Err(CookieError::BadSignature));
println!("tampered cookie correctly rejected");
}
This snippet shows a compact, self-contained approach to tamper-proof session cookies without any server-side session store. The idea is to serialize a small session payload, append an HMAC-SHA256 tag computed with a secret key, and hand the whole thing to the browser. Because the client never sees the key, it cannot forge a valid tag, and the server can verify authenticity on every request by recomputing the MAC over the received payload.
In session.rs the Session struct carries the claims that need to survive a round trip — a user_id, a monotonic issued_at, and an expires_at used for expiry checks. It derives Serialize/Deserialize so serde_json can turn it into a stable byte string; the JSON body is what gets signed, so any modification to it invalidates the tag.
cookie_codec.rs holds the actual crypto. CookieCodec::encode serializes the session, computes an HMAC over those bytes, and joins base64url(payload) and base64url(tag) with a . separator — the classic payload.signature shape also seen in JWTs. decode reverses this: it splits on the dot, base64-decodes both halves, then calls verify_slice from the hmac crate. That verification is the security-critical step. It is done with a constant-time comparison to avoid timing side channels; a naive == on the tag bytes could leak how many leading bytes matched and let an attacker recover a valid MAC byte by byte. Only after the signature checks out does the code parse the JSON and reject expired sessions, so untrusted input is never deserialized as trusted state before authentication.
The error type distinguishes BadFormat, BadSignature, and Expired so callers can respond appropriately — usually all three collapse to "log in again," but keeping them separate aids logging.
main.rs demonstrates the full loop and a forgery attempt: flipping a byte in the payload makes decode return BadSignature. The main trade-off of stateless cookies is revocation — a signed cookie stays valid until it expires, so short lifetimes and a rotatable key matter. Base64url keeps the token cookie-safe, and using Hmac<Sha256> keeps the dependency surface small and the verification fast.
Related snips
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post
data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)
HMAC signed API requests for webhook and partner integrity
#!/usr/bin/env bash
set -euo pipefail
export VAULT_ADDR="https://vault.internal:8200"
export VAULT_TOKEN="${VAULT_TOKEN:?missing VAULT_TOKEN}"
Secrets management with environment isolation and Vault
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
package files
import (
"context"
"time"
Presigned S3 upload URLs (AWS SDK v2)
<%# private stream: turbo signs the serialized record name %>
<%= turbo_stream_from current_user %>
<section class="notifications">
<h1>Notifications</h1>
Turbo Streams + authorization: signed per-user stream name
Share this code
Here's the card — post it anywhere.