use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use hmac::{Hmac, KeyInit, Mac};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value as Json};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JwtError {
Malformed,
Decode,
BadSignature,
Expired,
NotYetValid,
}
impl std::fmt::Display for JwtError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
JwtError::Malformed => "malformed JWT",
JwtError::Decode => "could not decode JWT",
JwtError::BadSignature => "JWT signature is invalid",
JwtError::Expired => "JWT has expired",
JwtError::NotYetValid => "JWT is not yet valid",
};
f.write_str(s)
}
}
impl std::error::Error for JwtError {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Claims {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub iss: Option<String>,
pub role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
pub iat: i64,
pub exp: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Json>,
}
impl Claims {
pub fn api_key(role: impl Into<String>, iat: i64, exp: i64) -> Self {
Self {
iss: Some("supabase".to_string()),
role: role.into(),
sub: None,
email: None,
aud: None,
iat,
exp,
session_id: None,
extra: Map::new(),
}
}
pub fn pg_role(&self) -> &str {
match self.role.as_str() {
"service_role" => "service_role",
"authenticated" => "authenticated",
"anon" => "anon",
other if is_ident(other) => other,
_ => "anon",
}
}
pub fn is_user(&self) -> bool {
self.sub.as_deref().is_some_and(|s| !s.is_empty())
}
}
fn is_ident(s: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& s.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
}
pub fn sign(claims: &Claims, secret: &str) -> Result<String, JwtError> {
let header = Json::Object({
let mut m = Map::new();
m.insert("alg".to_string(), Json::String("HS256".to_string()));
m.insert("typ".to_string(), Json::String("JWT".to_string()));
m
});
let header_b64 = B64.encode(serde_json::to_vec(&header).map_err(|_| JwtError::Decode)?);
let payload_b64 = B64.encode(serde_json::to_vec(claims).map_err(|_| JwtError::Decode)?);
let signing_input = format!("{header_b64}.{payload_b64}");
let sig = sign_hs256(signing_input.as_bytes(), secret);
let sig_b64 = B64.encode(sig);
Ok(format!("{signing_input}.{sig_b64}"))
}
pub fn verify(token: &str, secret: &str, now: i64) -> Result<Claims, JwtError> {
let mut parts = token.split('.');
let header_b64 = parts.next().ok_or(JwtError::Malformed)?;
let payload_b64 = parts.next().ok_or(JwtError::Malformed)?;
let sig_b64 = parts.next().ok_or(JwtError::Malformed)?;
if parts.next().is_some() {
return Err(JwtError::Malformed);
}
if header_b64.is_empty() || payload_b64.is_empty() || sig_b64.is_empty() {
return Err(JwtError::Malformed);
}
let signing_input = format!("{header_b64}.{payload_b64}");
let expected = sign_hs256(signing_input.as_bytes(), secret);
let provided = B64.decode(sig_b64).map_err(|_| JwtError::Decode)?;
if !constant_time_eq(&expected, &provided) {
return Err(JwtError::BadSignature);
}
let payload = B64.decode(payload_b64).map_err(|_| JwtError::Decode)?;
let claims: Claims = serde_json::from_slice(&payload).map_err(|_| JwtError::Decode)?;
if now > claims.exp {
return Err(JwtError::Expired);
}
Ok(claims)
}
fn sign_hs256(data: &[u8], secret: &str) -> Vec<u8> {
let mut mac =
<HmacSha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts a key of any length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &str = "super-secret-jwt-token-with-at-least-32-characters-long";
fn now() -> i64 {
1_700_000_000
}
fn user_claims() -> Claims {
Claims {
iss: Some("http://localhost:54321/auth/v1".to_string()),
role: "authenticated".to_string(),
sub: Some("11111111-1111-1111-1111-111111111111".to_string()),
email: Some("alice@example.com".to_string()),
aud: Some("authenticated".to_string()),
iat: now(),
exp: now() + 3600,
session_id: Some("22222222-2222-2222-2222-222222222222".to_string()),
extra: Map::new(),
}
}
#[test]
fn round_trip_authenticated() {
let claims = user_claims();
let token = sign(&claims, SECRET).unwrap();
let back = verify(&token, SECRET, now()).unwrap();
assert_eq!(back, claims);
assert_eq!(back.pg_role(), "authenticated");
assert!(back.is_user());
}
#[test]
fn round_trip_anon_and_service_role() {
for role in ["anon", "service_role"] {
let claims = Claims::api_key(role, now(), now() + 10_000);
let token = sign(&claims, SECRET).unwrap();
let back = verify(&token, SECRET, now()).unwrap();
assert_eq!(back.role, role);
assert_eq!(back.pg_role(), role);
assert_eq!(back.iss.as_deref(), Some("supabase"));
assert!(!back.is_user());
}
}
#[test]
fn tampered_signature_is_rejected() {
let token = sign(&user_claims(), SECRET).unwrap();
let (rest, sig) = token.rsplit_once('.').unwrap();
let mut sig_chars: Vec<char> = sig.chars().collect();
sig_chars[0] = if sig_chars[0] == 'A' { 'B' } else { 'A' };
let tampered = format!("{rest}.{}", sig_chars.into_iter().collect::<String>());
assert_eq!(
verify(&tampered, SECRET, now()),
Err(JwtError::BadSignature)
);
}
#[test]
fn tampered_payload_is_rejected() {
let token = sign(&user_claims(), SECRET).unwrap();
let mut parts: Vec<&str> = token.split('.').collect();
let forged = Claims::api_key("service_role", now(), now() + 3600);
let forged_b64 = B64.encode(serde_json::to_vec(&forged).unwrap());
parts[1] = &forged_b64;
let forged_token = parts.join(".");
assert_eq!(
verify(&forged_token, SECRET, now()),
Err(JwtError::BadSignature)
);
}
#[test]
fn wrong_secret_is_rejected() {
let token = sign(&user_claims(), SECRET).unwrap();
assert_eq!(
verify(&token, "a-different-secret-value-entirely", now()),
Err(JwtError::BadSignature)
);
}
#[test]
fn expired_token_is_rejected() {
let mut claims = user_claims();
claims.exp = now() - 1;
let token = sign(&claims, SECRET).unwrap();
assert_eq!(verify(&token, SECRET, now()), Err(JwtError::Expired));
}
#[test]
fn not_expired_at_exact_boundary() {
let mut claims = user_claims();
claims.exp = now();
let token = sign(&claims, SECRET).unwrap();
assert!(verify(&token, SECRET, now()).is_ok());
}
#[test]
fn malformed_tokens_are_rejected() {
for bad in ["", "abc", "a.b", "a.b.c.d", ".b.c", "a..c"] {
assert!(
verify(bad, SECRET, now()).is_err(),
"expected error for {bad:?}"
);
}
}
#[test]
fn extra_claims_round_trip() {
let mut claims = user_claims();
claims.extra.insert(
"app_metadata".to_string(),
serde_json::json!({"provider": "email"}),
);
let token = sign(&claims, SECRET).unwrap();
let back = verify(&token, SECRET, now()).unwrap();
assert_eq!(
back.extra.get("app_metadata"),
Some(&serde_json::json!({"provider": "email"}))
);
}
#[test]
fn unknown_role_falls_back_to_anon() {
let claims = Claims::api_key("'; DROP TABLE users; --", now(), now() + 100);
assert_eq!(claims.pg_role(), "anon");
}
}