<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dmitry</title>
    <description>The latest articles on DEV Community by Dmitry (@dmi03).</description>
    <link>https://dev.to/dmi03</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4101271%2F2e885109-cd2b-4c9b-b1e7-21e73fc3ed51.jpg</url>
      <title>DEV Community: Dmitry</title>
      <link>https://dev.to/dmi03</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dmi03"/>
    <language>en</language>
    <item>
      <title>Stop passing JWTs around as dicts</title>
      <dc:creator>Dmitry</dc:creator>
      <pubDate>Sun, 30 Aug 2026 10:56:16 +0000</pubDate>
      <link>https://dev.to/dmi03/stop-passing-jwts-around-as-dicts-4g2o</link>
      <guid>https://dev.to/dmi03/stop-passing-jwts-around-as-dicts-4g2o</guid>
      <description>&lt;p&gt;Every Python codebase I have read that deals with JWTs ends up in the same&lt;br&gt;
place. A helper decodes the token, a few &lt;code&gt;if&lt;/code&gt; statements check &lt;code&gt;exp&lt;/code&gt; and maybe&lt;br&gt;
&lt;code&gt;iss&lt;/code&gt;, and what comes out the other side is a &lt;code&gt;dict[str, Any]&lt;/code&gt;. From then on the&lt;br&gt;
claims are string keys. &lt;code&gt;payload["sub"]&lt;/code&gt; works, &lt;code&gt;payload["user_id"]&lt;/code&gt; returns a&lt;br&gt;
&lt;code&gt;KeyError&lt;/code&gt; at three in the morning, and nothing in your editor knows the&lt;br&gt;
difference.&lt;/p&gt;

&lt;p&gt;Meanwhile the same project has Pydantic models for every request body, because&lt;br&gt;
of course it does. The token — the one piece of data that arrives from an&lt;br&gt;
untrusted source and decides who the caller is — is the only thing still typed&lt;br&gt;
as a dictionary.&lt;/p&gt;

&lt;p&gt;So I wrote &lt;a href="https://github.com/dmi03/pydantic-jwt" rel="noopener noreferrer"&gt;pydantic-jwt&lt;/a&gt;, which lets you&lt;br&gt;
declare a token the way you declare everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_jwt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ConfigDict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Exp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JWTModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JWTModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfigDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;encoding_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;decoding_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Exp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one class is both ends of the flow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user-42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;  &lt;span class="c1"&gt;# 'user-42' — a str, and your editor knows it
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;from_token()&lt;/code&gt; parses the token, validates the claims, and verifies the&lt;br&gt;
signature. If any of that fails you get an exception, not a dict you have to&lt;br&gt;
remember to check.&lt;/p&gt;
&lt;h2&gt;
  
  
  Claims that check themselves
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Exp&lt;/code&gt;, &lt;code&gt;Nbf&lt;/code&gt; and &lt;code&gt;Iat&lt;/code&gt; are annotated &lt;code&gt;int&lt;/code&gt; types that compare against the&lt;br&gt;
current clock. &lt;code&gt;IssClaim&lt;/code&gt; and &lt;code&gt;AudClaim&lt;/code&gt; compare against a value you expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_jwt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AudClaim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Exp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IssClaim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JWTModel&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IncomingToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JWTModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfigDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decoding_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;PUBLIC_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Exp&lt;/span&gt;
    &lt;span class="n"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IssClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://auth.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nc"&gt;AudClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing-api&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They are plain &lt;code&gt;Annotated&lt;/code&gt; metadata, so they compose with anything else Pydantic&lt;br&gt;
can do to a field, and you can write your own by subclassing &lt;code&gt;Claim&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;iss&lt;/code&gt; and &lt;code&gt;aud&lt;/code&gt; stop mattering the moment more than one service shares a signing&lt;br&gt;
key — without &lt;code&gt;aud&lt;/code&gt;, a token minted for your low-privilege service is accepted&lt;br&gt;
by the high-privilege one. It is the kind of check everybody knows about and&lt;br&gt;
half of us skip because it is one more &lt;code&gt;if&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Failures are just ValidationErrors
&lt;/h2&gt;

&lt;p&gt;This is the part that turned out to matter most in practice. An expired token, a&lt;br&gt;
forged token and a token with a missing claim all fail through Pydantic's normal&lt;br&gt;
error path. Which means a FastAPI dependency is four lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;current_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;HTTPAuthorizationCredentials&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bearer_scheme&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid or expired token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="n"&gt;CurrentToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_token&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;


&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;me&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CurrentToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;object&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scopes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ValueError&lt;/code&gt; catches the whole family, because both &lt;code&gt;ValidationError&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;PydanticCustomError&lt;/code&gt; subclass it. If you want to tell "expired" from "forged" —&lt;br&gt;
the first means &lt;em&gt;go refresh&lt;/em&gt;, the second means &lt;em&gt;log in again&lt;/em&gt; — the error &lt;code&gt;type&lt;/code&gt;&lt;br&gt;
carries it, and there is a&lt;br&gt;
&lt;a href="https://pydantic-jwt.dmi03.com/integrations/fastapi/#6-distinguishing-the-failure" rel="noopener noreferrer"&gt;worked example&lt;/a&gt;&lt;br&gt;
in the docs.&lt;/p&gt;

&lt;p&gt;The endpoint body gets a typed object. &lt;code&gt;token.scopes&lt;/code&gt; autocompletes. &lt;code&gt;mypy&lt;/code&gt;&lt;br&gt;
catches the typo that &lt;code&gt;payload["scopes"]&lt;/code&gt; would have discovered in production.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug I shipped, and how it got fixed
&lt;/h2&gt;

&lt;p&gt;Here is the part I would rather not write, and the part that is actually worth&lt;br&gt;
reading.&lt;/p&gt;

&lt;p&gt;The model accepts a token string &lt;em&gt;and&lt;/em&gt; a claims dict — it has to, or the&lt;br&gt;
constructor could not build a token you are about to sign. Which means this&lt;br&gt;
looks completely reasonable and is a hole:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;   &lt;span class="c1"&gt;# DANGEROUS
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FastAPI parses the JSON body straight into the model. A client sends&lt;br&gt;
&lt;code&gt;{"sub": "admin"}&lt;/code&gt; and gets an &lt;code&gt;AccessToken&lt;/code&gt; instance that no signature was ever&lt;br&gt;
checked against. No key needed — the attacker just does not send a string.&lt;/p&gt;

&lt;p&gt;Version &lt;code&gt;0.2.0&lt;/code&gt; documented this loudly and left it. Documenting a hole is not&lt;br&gt;
fixing it, so &lt;code&gt;1.0.0&lt;/code&gt; added an off switch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IncomingToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JWTModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfigDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;decoding_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;verified_only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Exp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;verified_only=True&lt;/code&gt; the model accepts nothing but a token string whose&lt;br&gt;
signature was verified. The dict path is refused with a&lt;br&gt;
&lt;code&gt;jwt_unverified_payload&lt;/code&gt; error, so a &lt;code&gt;JWTModel&lt;/code&gt; is safe as a field type.&lt;/p&gt;

&lt;p&gt;Getting there took three attempts. The two that failed are instructive if you&lt;br&gt;
ever write custom Pydantic schemas: defining &lt;code&gt;__init__&lt;/code&gt; on a model flips&lt;br&gt;
pydantic-core's &lt;code&gt;custom_init&lt;/code&gt; flag, after which &lt;em&gt;all&lt;/em&gt; validation routes through&lt;br&gt;
your &lt;code&gt;__init__&lt;/code&gt; — including &lt;code&gt;model_validate()&lt;/code&gt;. Any validation context the&lt;br&gt;
caller passed is replaced by whatever your &lt;code&gt;__init__&lt;/code&gt; supplies. The signal I was&lt;br&gt;
using to mark "this came from the constructor" was destroyed by the act of&lt;br&gt;
sending it. Reading the config in the validator instead of threading a flag&lt;br&gt;
through the context sidesteps the whole problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately does not do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Revocation.&lt;/strong&gt; A JWT is valid until it expires. Give tokens a &lt;code&gt;jti&lt;/code&gt;, keep
lifetimes short, check a denylist yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWKS.&lt;/strong&gt; No key-fetching client. Pass the right key per call; &lt;code&gt;JWTStr&lt;/code&gt; lets
you read the &lt;code&gt;kid&lt;/code&gt; header before verifying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encryption.&lt;/strong&gt; Tokens are signed, not encrypted — anyone holding one can read
the claims.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI.&lt;/strong&gt; There is no FastAPI dependency in the package. It works with
anything built on Pydantic; the framework integration is an example, not a
coupling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing it does do by construction: the algorithm comes from your&lt;br&gt;
configuration and never from the token's &lt;code&gt;alg&lt;/code&gt; header. That is what prevents&lt;br&gt;
&lt;em&gt;algorithm confusion&lt;/em&gt;, where an attacker re-signs an &lt;code&gt;RS256&lt;/code&gt; token as &lt;code&gt;HS256&lt;/code&gt;&lt;br&gt;
using your public key as the HMAC secret.&lt;/p&gt;

&lt;p&gt;The full list of sharp edges lives on the&lt;br&gt;
&lt;a href="https://pydantic-jwt.dmi03.com/guide/security/" rel="noopener noreferrer"&gt;security notes&lt;/a&gt; page, which I&lt;br&gt;
would rather you read than my marketing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Docs:&lt;/strong&gt; &lt;a href="https://pydantic-jwt.dmi03.com" rel="noopener noreferrer"&gt;https://pydantic-jwt.dmi03.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/dmi03/pydantic-jwt" rel="noopener noreferrer"&gt;https://github.com/dmi03/pydantic-jwt&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyPI:&lt;/strong&gt; &lt;a href="https://pypi.org/project/pydantic-jwt/" rel="noopener noreferrer"&gt;https://pypi.org/project/pydantic-jwt/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;pydantic-jwt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python 3.10+, Pydantic 2.10+, PyJWT under the hood.&lt;/p&gt;

&lt;p&gt;I am the author, and this is the first release I consider stable. If you try it&lt;br&gt;
and something is awkward, open an issue — at this stage the design is still&lt;br&gt;
cheap to change.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>webdev</category>
      <category>security</category>
    </item>
  </channel>
</rss>
