Learn Python Series (#52) - Authentication & Security

in StemSocialyesterday (edited)

Learn Python Series (#52) - Authentication & Security

python-logo.png

Repository

What will I learn

  • You will learn how authentication differs from authorization and why both matter;
  • how JWT tokens work and when to use them vs sessions;
  • proper password hashing with bcrypt and why plaintext is catastrophic;
  • implementing OAuth2 password flow in FastAPI;
  • role-based access control with dependency injection;
  • common security vulnerabilities (SQL injection, CORS, XSS) and how to prevent them;
  • refresh token rotation and token revocation strategies.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Python 3(.11+) distribution;
  • The ambition to learn Python programming.

Difficulty

  • Intermediate, advanced

Curriculum (of the Learn Python Series):

GitHub Account

https://github.com/realScipio

Learn Python Series (#52) - Authentication & Security

Right now, our API from the previous episodes is an open door. Anyone who knows the URL can create, read, update, and delete anything they want. In development, that's fine — you need fast iteration without auth overhead slowing you down. In production, that's a catastrophe waiting to happen. And it will happen, sooner than you think.

Authentication ("who are you?") and authorization ("what are you allowed to do?") are non-negotiable for any real API. And here's the uncomfortable truth: most security breaches don't exploit exotic zero-day vulnerabilities — they exploit basic mistakes. Passwords stored in plaintext. Tokens that never expire. SQL injection through unsanitized input. The boring stuff that developers skip because "I'll add security later." Later never comes, and when it does, it comes with a data breach notification.

Nota bene: Security is the one area where "I'll add it later" always backfires. If you build your API without auth and then bolt it on afterward, you'll miss endpoints, break assumptions baked into your data flow, and create gaps that attackers walk right through. Build it in from the start — which is exactly what we're doing in this episode.

Authentication vs Authorization — Two Separate Concerns

Let's be precise about terminology, because conflating these two concepts leads to security gaps.

Authentication answers: who are you? This is the login step — credentials, tokens, biometrics, whatever proves identity.

Authorization answers: what are you allowed to do? This is the permission step — roles, scopes, access control lists.

Example: authentication proves you're user #42. Authorization determines whether user #42 can delete item #100 (maybe they can only delete their own items, not someone else's).

These are separate concerns and should be implemented separately. Authentication always comes first — you must know WHO someone is before deciding WHAT they can do. A common mistake is mixing them into one function that both verifies identity and checks permissions. Keep them apart; your future self will thank you when you need to change one without touching the other.

Why Password Hashing Matters — The Bcrypt Imperative

Never store passwords in plaintext. Ever. If your database leaks (and databases leak — it's a matter of when, not if), plaintext passwords compromise every account. Worse: people reuse passwords across services, so your leak becomes someone else's breach too.

The solution is one-way hashing: transform the password into a fixed-length string that cannot be reversed back to the original. But not just any hash — you need a hash that is intentionally slow.

Why slow? Because speed helps attackers. SHA-256 can hash billions of passwords per second on a modern GPU. Bcrypt is designed to be computationally expensive, making brute-force attacks impractical even with dedicated hardware.

from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(password: str) -> str:
    return pwd_context.hash(password)

def verify_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)

# Usage
hashed = hash_password("secretpassword")
print(hashed)
# "$2b$12$KIXn8vQ..." — different every time!

print(verify_password("secretpassword", hashed))   # True
print(verify_password("wrongpassword", hashed))     # False

Notice something important: hashing the same password twice produces different hashes. That's because bcrypt automatically generates a random salt (embedded in the hash output) before hashing. This means even if two users pick the same password, their stored hashes will differ — defeating rainbow table attacks entirely.

The $2b$12$ prefix tells you: bcrypt version 2b, cost factor 12 (meaning 2^12 = 4096 rounds of hashing). You can increase the cost factor to make it slower as hardware gets faster:

# Increase work factor for stronger hashing (slower but more secure)
pwd_context = CryptContext(
    schemes=["bcrypt"],
    deprecated="auto",
    bcrypt__rounds=14  # 2^14 = 16384 rounds — ~4x slower than default 12
)

Passlib (the CryptContext wrapper we're using) also handles algorithm migration transparently. If you set deprecated="auto", it will re-hash passwords using the current scheme whenever a user logs in — so you can upgrade from bcrypt to argon2id (or any future scheme) without forcing password resets.

JWT Tokens — Stateless Authentication

JSON Web Tokens (JWT) are the dominant approach for API authentication. A JWT is a signed JSON payload containing claims — statements about the user:

header.payload.signature

The header specifies the algorithm (HS256, RS256). The payload contains claims (user ID, expiration, roles). The signature proves the token wasn't tampered with — it's a cryptographic MAC of header + payload using a secret key.

Here's what makes JWTs powerful: they enable stateless authentication. The server doesn't store sessions or look anything up in a database to validate a request. Everything needed to verify the user's identity is contained in the token itself. This scales horizontally — any server in your cluster can verify any token without shared state.

The tradeoff? You cannot revoke a JWT without additional infrastructure. With server-side sessions, revoking access is trivial — delete the session from the database. With JWTs, the token remains valid until it expires. This is why short expiration times (15-30 minutes) combined with refresh tokens are the standard pattern.

Creating and Verifying JWT Tokens

from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt

SECRET_KEY = "your-secret-key-change-this-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

def create_access_token(
    data: dict,
    expires_delta: timedelta | None = None
) -> str:
    to_encode = data.copy()
    expire = datetime.now(timezone.utc) + (
        expires_delta or timedelta(minutes=15)
    )
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

The sub (subject) claim identifies the user. The exp (expiration) claim ensures tokens don't live forever. Never create tokens without expiration — a stolen token without expiry is permanent access.

Now the verification side — this is where FastAPI's dependency injection shines:

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

async def get_current_user(token: str = Depends(oauth2_scheme)):
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        email: str = payload.get("sub")
        if email is None:
            raise credentials_exception
    except JWTError:
        raise credentials_exception

    user = get_user_by_email(email)  # your DB lookup
    if user is None:
        raise credentials_exception
    return user

OAuth2PasswordBearer(tokenUrl="token") does two things: it extracts the token from the Authorization: Bearer <token> header automatically, and it tells Swagger UI where the login endpoint is (so the "Authorize" button works in the docs — neat ;-)).

The WWW-Authenticate: Bearer header in the exception is not decoration — it's required by the HTTP spec (RFC 6750) to tell the client that Bearer authentication is expected.

The Complete Login Flow

Let's wire everything together — a login endpoint that authenticates credentials and returns a JWT:

from fastapi import FastAPI
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session

app = FastAPI()

@app.post("/token")
def login(
    form_data: OAuth2PasswordRequestForm = Depends(),
    db: Session = Depends(get_db)
):
    user = authenticate_user(db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token = create_access_token(
        data={"sub": user.email},
        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    )
    return {"access_token": access_token, "token_type": "bearer"}

def authenticate_user(db: Session, email: str, password: str):
    user = db.query(User).filter(User.email == email).first()
    if not user:
        # Still verify a dummy hash to prevent timing attacks
        verify_password("dummy", "$2b$12$dummyhashvalue")
        return None
    if not verify_password(password, user.hashed_password):
        return None
    return user

Notice the timing attack prevention: when the user doesn't exist, we still run verify_password against a dummy hash. Without this, an attacker can measure response times — a fast response means "user doesn't exist" and a slower one means "user exists but wrong password." Verifying a dummy hash equalizes the response time regardless of whether the user exists. Subtle, but this is the difference between "secure" and "actually secure."

OAuth2PasswordRequestForm expects form data with username and password fields (this is the OAuth2 spec — the field is called username even if you're using email addresses).

Protected Endpoints and Role-Based Access Control

Once you have get_current_user, protecting endpoints is trivial:

@app.get("/users/me")
def read_users_me(current_user: User = Depends(get_current_user)):
    return current_user

@app.post("/items/")
def create_item(
    item: ItemCreate,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db)
):
    db_item = Item(**item.model_dump(), owner_id=current_user.id)
    db.add(db_item)
    db.commit()
    return db_item

No valid token = 401 Unauthorized. The dependency chain handles everything.

But authentication alone isn't enough. You also need authorization — what can this authenticated user do? The cleanest approach is role-based access control (RBAC) implemented as a dependency factory:

from enum import Enum
from typing import Annotated

class Role(str, Enum):
    USER = "user"
    MODERATOR = "moderator"
    ADMIN = "admin"

# Define a hierarchy: admin > moderator > user
ROLE_HIERARCHY = {Role.USER: 0, Role.MODERATOR: 1, Role.ADMIN: 2}

def require_role(minimum_role: Role):
    def role_checker(current_user: User = Depends(get_current_user)):
        user_level = ROLE_HIERARCHY.get(current_user.role, 0)
        required_level = ROLE_HIERARCHY[minimum_role]
        if user_level < required_level:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Requires {minimum_role.value} role or higher"
            )
        return current_user
    return role_checker

# Type alias for convenience
AdminUser = Annotated[User, Depends(require_role(Role.ADMIN))]
ModUser = Annotated[User, Depends(require_role(Role.MODERATOR))]

@app.delete("/users/{user_id}")
def delete_user(user_id: int, admin: AdminUser, db: Session = Depends(get_db)):
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    db.delete(user)
    db.commit()
    return {"message": "User deleted"}

@app.put("/items/{item_id}/moderate")
def moderate_item(item_id: int, mod: ModUser, db: Session = Depends(get_db)):
    # Moderators and admins can moderate items
    ...

The role hierarchy approach is more flexible than checking exact roles — an admin automatically passes any moderator check. The Annotated type aliases make endpoint signatures clean and readable. Compare admin: AdminUser with the verbose current_user: User = Depends(require_role(Role.ADMIN)) — same behavior, much cleaner.

Scope-Based Permissions — Fine-Grained Access

For more granular control than roles, consider scopes (also called permissions). Roles say "you're an admin." Scopes say "you can read:users and write:items":

from fastapi.security import SecurityScopes

def get_current_user_with_scopes(
    security_scopes: SecurityScopes,
    token: str = Depends(oauth2_scheme)
):
    authenticate_value = (
        f'Bearer scope="{security_scopes.scope_str}"'
        if security_scopes.scopes else "Bearer"
    )
    credentials_exception = HTTPException(
        status_code=401,
        detail="Not enough permissions",
        headers={"WWW-Authenticate": authenticate_value},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        email = payload.get("sub")
        token_scopes = payload.get("scopes", [])
    except JWTError:
        raise credentials_exception

    for scope in security_scopes.scopes:
        if scope not in token_scopes:
            raise credentials_exception

    user = get_user_by_email(email)
    if user is None:
        raise credentials_exception
    return user

# Endpoints declare required scopes
@app.get("/users/", dependencies=[Security(get_current_user_with_scopes, scopes=["read:users"])])
def list_users(db: Session = Depends(get_db)):
    return db.query(User).all()

Embed scopes in the JWT when creating tokens: create_access_token(data={"sub": email, "scopes": ["read:users", "write:items"]}). This way the token itself carries the permissions — no database lookup needed for authorization decisions.

Common Security Vulnerabilities — and How to Prevent Them

SQL Injection

The classic. Never concatenate user input into SQL:

# CATASTROPHICALLY WRONG — DO NOT DO THIS
email = request.form["email"]
cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")

# If email = "' OR '1'='1' --"
# Resulting SQL: SELECT * FROM users WHERE email = '' OR '1'='1' --'
# Returns ALL users. Attacker now has your entire user table.

# CORRECT — parameterized queries
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))

# ALSO CORRECT — ORMs handle this automatically
user = db.query(User).filter(User.email == email).first()

SQLAlchemy (which we covered in episode #51) parameterizes all queries by default. This is one of many reasons to use an ORM instead of raw SQL — you get SQL injection protection for free.

CORS Misconfiguration

Cross-Origin Resource Sharing controls which websites can make requests to your API from JavaScript. Misconfigure it, and you've invited the entire internet to make authenticated requests on behalf of your users:

from fastapi.middleware.cors import CORSMiddleware

# WRONG — allows any origin with credentials
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],           # any website
    allow_credentials=True,         # with cookies/auth headers
    # = any website can steal your users' data
)

# CORRECT — explicit origins only
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "https://yourfrontend.com",
        "https://app.example.com",
    ],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)

allow_origins=["*"] with allow_credentials=True is the security equivalent of leaving your front door open with a sign that says "help yourself." Browsers actually block this combination (the spec forbids it), but don't rely on browser behavior for security.

Exposing Sensitive Data in Responses

Without explicit response models, you might accidentally leak password hashes, internal IDs, or other sensitive fields:

from pydantic import BaseModel

class UserResponse(BaseModel):
    id: int
    email: str
    role: str
    created_at: datetime
    # hashed_password is NOT included — filtered out automatically

    model_config = {"from_attributes": True}

@app.get("/users/me", response_model=UserResponse)
def read_users_me(current_user: User = Depends(get_current_user)):
    return current_user  # FastAPI filters to UserResponse fields only

The response_model parameter is your safety net. Even if your ORM model has 20 fields, only the ones in UserResponse make it to the client. Always use response models in production APIs — treat it as mandatory, not optional.

Secret Key Management

import secrets
# Generate a strong secret key
print(secrets.token_urlsafe(32))
# "V2x8Gk7-w3Mn_5HfQ6dR..."

Never hardcode secrets in source. Never commit them to git. Never use "secret" or "changeme" as your key (you'd be surprised how often this happens in production).

import os

SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
    raise ValueError("SECRET_KEY environment variable not set")

# Or use pydantic-settings for structured config
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    secret_key: str
    algorithm: str = "HS256"
    access_token_expire_minutes: int = 30

    model_config = {"env_file": ".env"}

settings = Settings()

Using pydantic-settings with a .env file is the modern Python approach. It validates your configuration at startup — if SECRET_KEY is missing, the application fails to start rather than silently using None.

Refresh Tokens and Token Rotation

Access tokens should expire quickly (15-30 minutes). But you don't want users re-entering their password every 15 minutes. The solution: refresh tokens.

def create_refresh_token(data: dict) -> str:
    to_encode = data.copy()
    expire = datetime.now(timezone.utc) + timedelta(days=7)
    to_encode.update({"exp": expire, "type": "refresh"})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

@app.post("/token")
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
    user = authenticate_user(db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(status_code=401, detail="Invalid credentials")

    access_token = create_access_token(data={"sub": user.email})
    refresh_token = create_refresh_token(data={"sub": user.email})
    return {
        "access_token": access_token,
        "refresh_token": refresh_token,
        "token_type": "bearer"
    }

@app.post("/token/refresh")
def refresh(token: str, db: Session = Depends(get_db)):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        if payload.get("type") != "refresh":
            raise HTTPException(status_code=401, detail="Not a refresh token")
        email = payload.get("sub")
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid refresh token")

    user = get_user_by_email(email)
    if not user:
        raise HTTPException(status_code=401, detail="User not found")

    new_access = create_access_token(data={"sub": email})
    new_refresh = create_refresh_token(data={"sub": email})
    return {
        "access_token": new_access,
        "refresh_token": new_refresh,
        "token_type": "bearer"
    }

The rotation part is key: each refresh returns a new refresh token, invalidating the old one. This limits the damage of a stolen refresh token — once the legitimate user refreshes, the stolen token becomes invalid. To fully implement rotation, you'd store refresh token IDs in the database and check that the incoming token hasn't been used before (a concept called refresh token reuse detection).

Rate Limiting — Slowing Down Attackers

Without rate limiting, an attacker can try thousands of password combinations per second against your login endpoint:

from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

@app.post("/token")
@limiter.limit("5/minute")
def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
    # ... login logic
    pass

@app.post("/users/register")
@limiter.limit("3/hour")
def register(request: Request, user_data: UserCreate, db: Session = Depends(get_db)):
    # ... registration logic
    pass

Five login attempts per minute per IP address makes brute-force impractical — even a modest 8-character password would take years. Registration limits prevent automated account creation spam.

For production deployments behind a reverse proxy (nginx, Cloudflare), make sure get_remote_address reads the correct header (X-Forwarded-For), otherwise you'll be rate-limiting your proxy's IP — which means rate-limiting all users at once.

HTTPS — Non-Negotiable in Production

JWT tokens sent over plain HTTP are visible to anyone on the network. An attacker on the same WiFi at a coffee shop can intercept your token and impersonate you for the token's entire lifetime. This is not theoretical — tools like Wireshark make it trivial.

Always use HTTPS in production. Use Let's Encrypt for free SSL certificates. No excuses.

Also set the Secure and HttpOnly flags on cookies if you're using cookie-based token storage:

from fastapi import Response

@app.post("/token")
def login(response: Response, form_data: OAuth2PasswordRequestForm = Depends()):
    # ... authenticate ...
    token = create_access_token(data={"sub": user.email})
    response.set_cookie(
        key="access_token",
        value=f"Bearer {token}",
        httponly=True,      # JavaScript can't read this cookie (XSS protection)
        secure=True,        # Only sent over HTTPS
        samesite="lax",     # CSRF protection
        max_age=1800        # 30 minutes
    )
    return {"message": "Login successful"}

httponly=True prevents XSS attacks from stealing the token via document.cookie. samesite="lax" prevents CSRF by restricting when the browser sends the cookie cross-origin. These flags cost nothing to add and block entire classes of attacks.

Putting It All Together — A Secured Mini-API

Let's combine everything we've covered into a cohesive authentication system:

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, EmailStr
from datetime import datetime, timedelta, timezone
from passlib.context import CryptContext
from jose import jwt, JWTError
from typing import Annotated

app = FastAPI(title="Secured API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://yourfrontend.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
SECRET_KEY = "load-from-env-in-production"
ALGORITHM = "HS256"

# --- Models ---
class UserCreate(BaseModel):
    email: EmailStr
    password: str

class UserResponse(BaseModel):
    id: int
    email: str
    role: str
    model_config = {"from_attributes": True}

# --- Auth helpers ---
def hash_password(password: str) -> str:
    return pwd_context.hash(password)

def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)

def create_token(data: dict, expires: timedelta) -> str:
    payload = {**data, "exp": datetime.now(timezone.utc) + expires}
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

async def get_current_user(token: str = Depends(oauth2_scheme)):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        email = payload.get("sub")
        if not email:
            raise HTTPException(status_code=401, detail="Invalid token")
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid token")
    user = get_user_by_email(email)  # your DB call
    if not user:
        raise HTTPException(status_code=401, detail="User not found")
    return user

CurrentUser = Annotated[dict, Depends(get_current_user)]

# --- Endpoints ---
@app.post("/token")
def login(form_data: OAuth2PasswordRequestForm = Depends()):
    user = authenticate_user(form_data.username, form_data.password)
    if not user:
        raise HTTPException(status_code=401, detail="Invalid credentials")
    token = create_token(
        {"sub": user.email, "role": user.role},
        timedelta(minutes=30)
    )
    return {"access_token": token, "token_type": "bearer"}

@app.get("/users/me", response_model=UserResponse)
def me(current_user: CurrentUser):
    return current_user

Clean, readable, and secure. The dependency injection chain (oauth2_schemeget_current_user → endpoint) is the backbone. Every protected endpoint just declares current_user: CurrentUser and authentication is handled.

What to remember from this one

Security is a layered discipline. In this episode, we covered the full stack:

  • Authentication proves identity; authorization controls permissions — separate concerns, separate code
  • Bcrypt hashing is slow by design — that slowness is the security feature, not a bug
  • JWT tokens enable stateless auth but can't be revoked without additional infrastructure
  • Timing attack prevention: always verify a dummy hash even when the user doesn't exist
  • OAuth2PasswordBearer integrates with FastAPI's dependency system and Swagger UI
  • RBAC with role hierarchies and Annotated type aliases keep endpoint signatures clean
  • Scopes provide fine-grained permission control embedded in the token itself
  • SQL injection: use parameterized queries or ORMs — never concatenate user input into SQL
  • CORS: explicit origins only, never ["*"] with credentials
  • Response models prevent leaking sensitive fields like password hashes
  • Secret management: environment variables or pydantic-settings, never hardcoded
  • Refresh token rotation with reuse detection limits stolen token damage
  • Rate limiting makes brute-force attacks impractical
  • HTTPS + cookie flags (httponly, secure, samesite) block entire attack classes for free

The developers who get breached aren't the ones who failed to implement cutting-edge zero-trust architecture. They're the ones who stored passwords in plaintext, forgot to validate input on one endpoint, or left allow_origins=["*"] in production. The patterns we covered today aren't exotic — they're table stakes. Make them habitual.

Thanks for your time — and as always, tot de volgende keer ;-)

@scipio

Sort:  

Thanks for your contribution to the STEMsocial community. Feel free to join us on discord to get to know the rest of us!

Please consider delegating to the @stemsocial account (85% of the curation rewards are returned).

Consider setting @stemsocial as a beneficiary of this post's rewards if you would like to support the community and contribute to its mission of promoting science and education on Hive.