diff --git a/Cargo.toml b/Cargo.toml
index a8ad41200..1420234fc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -60,6 +60,7 @@ defmt = ["dep:defmt", "heapless/defmt-03"]
 "proto-ipsec" = ["proto-ipsec-ah", "proto-ipsec-esp"]
 "proto-ipsec-ah" = []
 "proto-ipsec-esp" = []
+"proto-vlan" = ["medium-ethernet"]
 
 "socket" = []
 "socket-raw" = ["socket"]
diff --git a/ci.sh b/ci.sh
index ec20cc70e..6ec164a32 100755
--- a/ci.sh
+++ b/ci.sh
@@ -30,6 +30,7 @@ FEATURES_TEST=(
     "std,medium-ieee802154,proto-rpl,proto-sixlowpan,proto-sixlowpan-fragmentation,socket-udp"
     "std,medium-ip,proto-ipv4,proto-ipv6,socket-tcp,socket-udp"
     "std,medium-ethernet,medium-ip,medium-ieee802154,proto-ipv4,proto-ipv6,socket-raw,socket-udp,socket-tcp,socket-icmp,socket-dns,async"
+    "std,medium-ethernet,medium-ip,medium-ieee802154,proto-ipv4,proto-ipv6,proto-vlan,socket-raw,socket-udp,socket-tcp,socket-icmp,socket-dns,async"
     "std,medium-ieee802154,medium-ip,proto-ipv4,socket-raw"
     "std,medium-ethernet,proto-ipv4,proto-ipsec,socket-raw"
 )
@@ -39,7 +40,7 @@ FEATURES_TEST_NIGHTLY=(
 )
 
 FEATURES_CHECK=(
-    "medium-ip,medium-ethernet,medium-ieee802154,proto-ipv6,proto-ipv6,proto-igmp,proto-dhcpv4,proto-ipsec,socket-raw,socket-udp,socket-tcp,socket-icmp,socket-dns,async"
+    "medium-ip,medium-ethernet,medium-ieee802154,proto-ipv6,proto-ipv6,proto-igmp,proto-dhcpv4,proto-ipsec,proto-vlan,socket-raw,socket-udp,socket-tcp,socket-icmp,socket-dns,async"
     "defmt,medium-ip,medium-ethernet,proto-ipv6,proto-ipv6,proto-igmp,proto-dhcpv4,socket-raw,socket-udp,socket-tcp,socket-icmp,socket-dns,async"
     "defmt,alloc,medium-ip,medium-ethernet,proto-ipv6,proto-ipv6,proto-igmp,proto-dhcpv4,socket-raw,socket-udp,socket-tcp,socket-icmp,socket-dns,async"
 )
diff --git a/src/iface/interface/ethernet.rs b/src/iface/interface/ethernet.rs
index 4d29faa11..7aa9b3eb1 100644
--- a/src/iface/interface/ethernet.rs
+++ b/src/iface/interface/ethernet.rs
@@ -18,22 +18,80 @@ impl InterfaceInner {
             return None;
         }
 
-        match eth_frame.ethertype() {
+        #[cfg(feature = "proto-vlan")]
+        if let Some(vlan_config) = &self.vlan_config {
+            // Drop all frames without VLAN header
+            match vlan_config.outer_vlan_id {
+                Some(_) if eth_frame.ethertype() != EthernetProtocol::VlanOuter => return None,
+                None if eth_frame.ethertype() != EthernetProtocol::VlanInner => return None,
+                _ => (),
+            }
+        }
+
+        self.handle_ethertype(
+            sockets,
+            meta,
+            eth_frame.payload(),
+            eth_frame.ethertype(),
+            fragments,
+        )
+    }
+
+    fn handle_ethertype<'frame>(
+        &mut self,
+        sockets: &mut SocketSet,
+        meta: crate::phy::PacketMeta,
+        payload: &'frame [u8],
+        ethertype: EthernetProtocol,
+        fragments: &'frame mut FragmentsBuffer,
+    ) -> Option<EthernetPacket<'frame>> {
+        match ethertype {
             #[cfg(feature = "proto-ipv4")]
-            EthernetProtocol::Arp => self.process_arp(self.now, &eth_frame),
+            EthernetProtocol::Arp => self.process_arp(self.now, payload),
             #[cfg(feature = "proto-ipv4")]
             EthernetProtocol::Ipv4 => {
-                let ipv4_packet = check!(Ipv4Packet::new_checked(eth_frame.payload()));
+                let ipv4_packet = check!(Ipv4Packet::new_checked(payload));
 
                 self.process_ipv4(sockets, meta, &ipv4_packet, fragments)
                     .map(EthernetPacket::Ip)
             }
             #[cfg(feature = "proto-ipv6")]
             EthernetProtocol::Ipv6 => {
-                let ipv6_packet = check!(Ipv6Packet::new_checked(eth_frame.payload()));
+                let ipv6_packet = check!(Ipv6Packet::new_checked(payload));
                 self.process_ipv6(sockets, meta, &ipv6_packet)
                     .map(EthernetPacket::Ip)
             }
+            #[cfg(feature = "proto-vlan")]
+            EthernetProtocol::VlanInner | EthernetProtocol::VlanOuter => match &self.vlan_config {
+                Some(vlan_config) => {
+                    let vlan_packet = check!(VlanPacket::new_checked(payload));
+                    if ethertype == EthernetProtocol::VlanOuter
+                        && (vlan_config.outer_vlan_id.is_none()
+                            || !matches!(
+                                vlan_config.outer_vlan_id,
+                                Some(vid) if vid == vlan_packet.vlan_identifier()
+                            )
+                            || vlan_packet.ethertype() != EthernetProtocol::VlanInner)
+                    {
+                        return None;
+                    }
+                    if ethertype == EthernetProtocol::VlanInner
+                        && (vlan_packet.ethertype() == EthernetProtocol::VlanInner
+                            || vlan_packet.ethertype() == EthernetProtocol::VlanOuter
+                            || vlan_packet.vlan_identifier() != vlan_config.inner_vlan_id)
+                    {
+                        return None;
+                    }
+                    return self.handle_ethertype(
+                        sockets,
+                        meta,
+                        &payload[VlanPacket::<&[u8]>::header_len()..],
+                        vlan_packet.ethertype(),
+                        fragments,
+                    );
+                }
+                None => None,
+            },
             // Drop all other traffic.
             _ => None,
         }
diff --git a/src/iface/interface/ipv4.rs b/src/iface/interface/ipv4.rs
index 3a5a864ee..083cc9ecc 100644
--- a/src/iface/interface/ipv4.rs
+++ b/src/iface/interface/ipv4.rs
@@ -230,9 +230,9 @@ impl InterfaceInner {
     pub(super) fn process_arp<'frame>(
         &mut self,
         timestamp: Instant,
-        eth_frame: &EthernetFrame<&'frame [u8]>,
+        payload: &'frame [u8],
     ) -> Option<EthernetPacket<'frame>> {
-        let arp_packet = check!(ArpPacket::new_checked(eth_frame.payload()));
+        let arp_packet = check!(ArpPacket::new_checked(payload));
         let arp_repr = check!(ArpRepr::parse(&arp_packet));
 
         match arp_repr {
@@ -407,6 +407,14 @@ impl InterfaceInner {
         #[cfg(feature = "medium-ethernet")]
         if matches!(caps.medium, Medium::Ethernet) {
             tx_len += EthernetFrame::<&[u8]>::header_len();
+
+            #[cfg(feature = "proto-vlan")]
+            {
+                tx_len += self
+                    .vlan_config
+                    .map(|vlan_config| vlan_config.get_additional_header_length())
+                    .unwrap_or(0);
+            }
         }
 
         // Emit function for the Ethernet header.
@@ -418,11 +426,23 @@ impl InterfaceInner {
             frame.set_src_addr(src_addr);
             frame.set_dst_addr(frag.ipv4.dst_hardware_addr);
 
-            match repr.version() {
+            let ip_ethertype = match repr.version() {
                 #[cfg(feature = "proto-ipv4")]
-                IpVersion::Ipv4 => frame.set_ethertype(EthernetProtocol::Ipv4),
+                IpVersion::Ipv4 => EthernetProtocol::Ipv4,
                 #[cfg(feature = "proto-ipv6")]
-                IpVersion::Ipv6 => frame.set_ethertype(EthernetProtocol::Ipv6),
+                IpVersion::Ipv6 => EthernetProtocol::Ipv6,
+            };
+
+            #[cfg(feature = "proto-vlan")]
+            if let Some(vlan_config) = &self.vlan_config {
+                frame.set_ethertype(vlan_config.get_outer_ethertype());
+                vlan_config.emit_to_payload(frame.payload_mut(), ip_ethertype);
+            } else {
+                frame.set_ethertype(ip_ethertype);
+            }
+            #[cfg(not(feature = "proto-vlan"))]
+            {
+                frame.set_ethertype(ip_ethertype);
             }
         };
 
@@ -430,7 +450,19 @@ impl InterfaceInner {
             #[cfg(feature = "medium-ethernet")]
             if matches!(self.caps.medium, Medium::Ethernet) {
                 emit_ethernet(&IpRepr::Ipv4(frag.ipv4.repr), tx_buffer);
-                tx_buffer = &mut tx_buffer[EthernetFrame::<&[u8]>::header_len()..];
+                #[cfg(not(feature = "proto-vlan"))]
+                {
+                    tx_buffer = &mut tx_buffer[EthernetFrame::<&[u8]>::header_len()..];
+                }
+                #[cfg(feature = "proto-vlan")]
+                {
+                    tx_buffer = &mut tx_buffer[EthernetFrame::<&[u8]>::header_len()
+                        + self
+                            .vlan_config
+                            .as_ref()
+                            .map(|vlan_config| vlan_config.get_additional_header_length())
+                            .unwrap_or(0)..];
+                }
             }
 
             let mut packet =
diff --git a/src/iface/interface/mod.rs b/src/iface/interface/mod.rs
index 00f46d07d..fa23f3701 100644
--- a/src/iface/interface/mod.rs
+++ b/src/iface/interface/mod.rs
@@ -97,6 +97,8 @@ pub struct InterfaceInner {
     #[cfg(any(feature = "medium-ethernet", feature = "medium-ieee802154"))]
     neighbor_cache: NeighborCache,
     hardware_addr: HardwareAddress,
+    #[cfg(feature = "proto-vlan")]
+    vlan_config: Option<VlanConfig>,
     #[cfg(feature = "medium-ieee802154")]
     sequence_no: u8,
     #[cfg(feature = "medium-ieee802154")]
@@ -136,6 +138,9 @@ pub struct Config {
     /// Creating the interface panics if the address is not unicast.
     pub hardware_addr: HardwareAddress,
 
+    #[cfg(feature = "proto-vlan")]
+    pub vlan_config: Option<VlanConfig>,
+
     /// Set the IEEE802.15.4 PAN ID the interface will use.
     ///
     /// **NOTE**: we use the same PAN ID for destination and source.
@@ -148,6 +153,8 @@ impl Config {
         Config {
             random_seed: 0,
             hardware_addr,
+            #[cfg(feature = "proto-vlan")]
+            vlan_config: None,
             #[cfg(feature = "medium-ieee802154")]
             pan_id: None,
         }
@@ -220,6 +227,8 @@ impl Interface {
                 now,
                 caps,
                 hardware_addr: config.hardware_addr,
+                #[cfg(feature = "proto-vlan")]
+                vlan_config: config.vlan_config,
                 ip_addrs: Vec::new(),
                 #[cfg(feature = "proto-ipv4")]
                 any_ip: false,
@@ -859,12 +868,43 @@ impl InterfaceInner {
                     } => target_hardware_addr,
                 };
 
-                self.dispatch_ethernet(tx_token, arp_repr.buffer_len(), |mut frame| {
+                #[cfg(feature = "proto-vlan")]
+                let vlan_config = self.vlan_config.as_ref().copied();
+
+                #[cfg(feature = "proto-vlan")]
+                let buffer_len = arp_repr.buffer_len()
+                    + vlan_config
+                        .as_ref()
+                        .map(|vlan_config| vlan_config.get_additional_header_length())
+                        .unwrap_or(0);
+                #[cfg(not(feature = "proto-vlan"))]
+                let buffer_len = arp_repr.buffer_len();
+
+                self.dispatch_ethernet(tx_token, buffer_len, |mut frame| {
                     frame.set_dst_addr(dst_hardware_addr);
-                    frame.set_ethertype(EthernetProtocol::Arp);
 
-                    let mut packet = ArpPacket::new_unchecked(frame.payload_mut());
-                    arp_repr.emit(&mut packet);
+                    #[cfg(feature = "proto-vlan")]
+                    {
+                        let mut packet = if let Some(vlan_config) = vlan_config {
+                            frame.set_ethertype(vlan_config.get_outer_ethertype());
+                            vlan_config.emit_to_payload(frame.payload_mut(), EthernetProtocol::Arp);
+
+                            ArpPacket::new_unchecked(
+                                &mut frame.payload_mut()
+                                    [vlan_config.get_additional_header_length()..],
+                            )
+                        } else {
+                            frame.set_ethertype(EthernetProtocol::Arp);
+                            ArpPacket::new_unchecked(frame.payload_mut())
+                        };
+                        arp_repr.emit(&mut packet);
+                    }
+                    #[cfg(not(feature = "proto-vlan"))]
+                    {
+                        frame.set_ethertype(EthernetProtocol::Arp);
+                        let mut packet = ArpPacket::new_unchecked(frame.payload_mut());
+                        arp_repr.emit(&mut packet);
+                    }
                 })
             }
             EthernetPacket::Ip(packet) => {
@@ -994,14 +1034,44 @@ impl InterfaceInner {
                     target_protocol_addr: dst_addr,
                 };
 
-                if let Err(e) =
-                    self.dispatch_ethernet(tx_token, arp_repr.buffer_len(), |mut frame| {
-                        frame.set_dst_addr(EthernetAddress::BROADCAST);
-                        frame.set_ethertype(EthernetProtocol::Arp);
+                #[cfg(feature = "proto-vlan")]
+                let vlan_config = self.vlan_config.as_ref().copied();
 
-                        arp_repr.emit(&mut ArpPacket::new_unchecked(frame.payload_mut()))
-                    })
-                {
+                #[cfg(feature = "proto-vlan")]
+                let buffer_len = arp_repr.buffer_len()
+                    + vlan_config
+                        .as_ref()
+                        .map(|vlan_config| vlan_config.get_additional_header_length())
+                        .unwrap_or(0);
+                #[cfg(not(feature = "proto-vlan"))]
+                let buffer_len = arp_repr.buffer_len();
+
+                if let Err(e) = self.dispatch_ethernet(tx_token, buffer_len, |mut frame| {
+                    frame.set_dst_addr(EthernetAddress::BROADCAST);
+
+                    #[cfg(feature = "proto-vlan")]
+                    {
+                        let mut packet = if let Some(vlan_config) = vlan_config {
+                            frame.set_ethertype(vlan_config.get_outer_ethertype());
+                            vlan_config.emit_to_payload(frame.payload_mut(), EthernetProtocol::Arp);
+
+                            ArpPacket::new_unchecked(
+                                &mut frame.payload_mut()
+                                    [vlan_config.get_additional_header_length()..],
+                            )
+                        } else {
+                            frame.set_ethertype(EthernetProtocol::Arp);
+                            ArpPacket::new_unchecked(frame.payload_mut())
+                        };
+                        arp_repr.emit(&mut packet);
+                    }
+                    #[cfg(not(feature = "proto-vlan"))]
+                    {
+                        frame.set_ethertype(EthernetProtocol::Arp);
+                        let mut packet = ArpPacket::new_unchecked(frame.payload_mut());
+                        arp_repr.emit(&mut packet);
+                    }
+                }) {
                     net_debug!("Failed to dispatch ARP request: {:?}", e);
                     return Err(DispatchError::NeighborPending);
                 }
@@ -1094,6 +1164,14 @@ impl InterfaceInner {
         #[cfg(feature = "medium-ethernet")]
         if matches!(self.caps.medium, Medium::Ethernet) {
             total_len = EthernetFrame::<&[u8]>::buffer_len(total_len);
+            #[cfg(feature = "proto-vlan")]
+            {
+                total_len += self
+                    .vlan_config
+                    .as_ref()
+                    .map(|vlan_config| vlan_config.get_additional_header_length())
+                    .unwrap_or(0);
+            }
         }
 
         // If the medium is Ethernet, then we need to retrieve the destination hardware address.
@@ -1122,11 +1200,23 @@ impl InterfaceInner {
             frame.set_src_addr(src_addr);
             frame.set_dst_addr(dst_hardware_addr);
 
-            match repr.version() {
+            let ip_ethertype = match repr.version() {
                 #[cfg(feature = "proto-ipv4")]
-                IpVersion::Ipv4 => frame.set_ethertype(EthernetProtocol::Ipv4),
+                IpVersion::Ipv4 => EthernetProtocol::Ipv4,
                 #[cfg(feature = "proto-ipv6")]
-                IpVersion::Ipv6 => frame.set_ethertype(EthernetProtocol::Ipv6),
+                IpVersion::Ipv6 => EthernetProtocol::Ipv6,
+            };
+
+            #[cfg(feature = "proto-vlan")]
+            if let Some(vlan_config) = &self.vlan_config {
+                frame.set_ethertype(vlan_config.get_outer_ethertype());
+                vlan_config.emit_to_payload(frame.payload_mut(), ip_ethertype);
+            } else {
+                frame.set_ethertype(ip_ethertype);
+            }
+            #[cfg(not(feature = "proto-vlan"))]
+            {
+                frame.set_ethertype(ip_ethertype);
             }
 
             Ok(())
@@ -1202,7 +1292,22 @@ impl InterfaceInner {
                             #[cfg(feature = "medium-ethernet")]
                             if matches!(self.caps.medium, Medium::Ethernet) {
                                 emit_ethernet(&ip_repr, tx_buffer)?;
-                                tx_buffer = &mut tx_buffer[EthernetFrame::<&[u8]>::header_len()..];
+                                #[cfg(not(feature = "proto-vlan"))]
+                                {
+                                    tx_buffer =
+                                        &mut tx_buffer[EthernetFrame::<&[u8]>::header_len()..];
+                                }
+                                #[cfg(feature = "proto-vlan")]
+                                {
+                                    tx_buffer = &mut tx_buffer[EthernetFrame::<&[u8]>::header_len()
+                                        + self
+                                            .vlan_config
+                                            .as_ref()
+                                            .map(|vlan_config| {
+                                                vlan_config.get_additional_header_length()
+                                            })
+                                            .unwrap_or(0)..];
+                                }
                             }
 
                             // Change the offset for the next packet.
@@ -1229,7 +1334,21 @@ impl InterfaceInner {
                         #[cfg(feature = "medium-ethernet")]
                         if matches!(self.caps.medium, Medium::Ethernet) {
                             emit_ethernet(&ip_repr, tx_buffer)?;
-                            tx_buffer = &mut tx_buffer[EthernetFrame::<&[u8]>::header_len()..];
+                            #[cfg(not(feature = "proto-vlan"))]
+                            {
+                                tx_buffer = &mut tx_buffer[EthernetFrame::<&[u8]>::header_len()..];
+                            }
+                            #[cfg(feature = "proto-vlan")]
+                            {
+                                tx_buffer = &mut tx_buffer[EthernetFrame::<&[u8]>::header_len()
+                                    + self
+                                        .vlan_config
+                                        .as_ref()
+                                        .map(|vlan_config| {
+                                            vlan_config.get_additional_header_length()
+                                        })
+                                        .unwrap_or(0)..];
+                            }
                         }
 
                         emit_ip(&ip_repr, tx_buffer);
diff --git a/src/iface/interface/tests/ipv4.rs b/src/iface/interface/tests/ipv4.rs
index 316a9d58a..39fcfddb4 100644
--- a/src/iface/interface/tests/ipv4.rs
+++ b/src/iface/interface/tests/ipv4.rs
@@ -2,7 +2,7 @@ use super::*;
 
 #[rstest]
 #[case(Medium::Ethernet)]
-#[cfg(feature = "medium-ethernet")]
+#[cfg(all(feature = "medium-ethernet", not(feature = "proto-vlan")))]
 fn test_any_ip_accept_arp(#[case] medium: Medium) {
     let mut buffer = [0u8; 64];
     #[allow(non_snake_case)]
@@ -58,6 +58,91 @@ fn test_any_ip_accept_arp(#[case] medium: Medium) {
         .is_some());
 }
 
+#[rstest]
+#[case(Medium::Ethernet)]
+#[cfg(feature = "proto-vlan")]
+fn test_any_ip_accept_arp_vlan(#[case] medium: Medium) {
+    let mut buffer = [0u8; 64];
+    #[allow(non_snake_case)]
+    fn ETHERNET_FRAME_ARP(buffer: &mut [u8]) -> &[u8] {
+        let ethernet_repr = EthernetRepr {
+            src_addr: EthernetAddress::from_bytes(&[0x02, 0x02, 0x02, 0x02, 0x02, 0x03]),
+            dst_addr: EthernetAddress::from_bytes(&[0x02, 0x02, 0x02, 0x02, 0x02, 0x02]),
+            ethertype: EthernetProtocol::VlanOuter,
+        };
+        let outer_vlan_repr = VlanRepr {
+            vlan_identifier: 200,
+            drop_eligible_indicator: false,
+            priority_code_point: Pcp::Be,
+            ethertype: EthernetProtocol::VlanInner,
+        };
+        let inner_vlan_repr = VlanRepr {
+            vlan_identifier: 100,
+            drop_eligible_indicator: false,
+            priority_code_point: Pcp::Be,
+            ethertype: EthernetProtocol::Arp,
+        };
+        let frame_repr = ArpRepr::EthernetIpv4 {
+            operation: ArpOperation::Request,
+            source_hardware_addr: EthernetAddress::from_bytes(&[
+                0x02, 0x02, 0x02, 0x02, 0x02, 0x03,
+            ]),
+            source_protocol_addr: Ipv4Address::from_bytes(&[192, 168, 1, 2]),
+            target_hardware_addr: EthernetAddress::from_bytes(&[
+                0x02, 0x02, 0x02, 0x02, 0x02, 0x02,
+            ]),
+            target_protocol_addr: Ipv4Address::from_bytes(&[192, 168, 1, 3]),
+        };
+        let mut frame = EthernetFrame::new_unchecked(&mut buffer[..]);
+        ethernet_repr.emit(&mut frame);
+
+        let mut frame = VlanPacket::new_unchecked(&mut buffer[ethernet_repr.buffer_len()..]);
+        outer_vlan_repr.emit(&mut frame);
+
+        let mut frame = VlanPacket::new_unchecked(
+            &mut buffer[ethernet_repr.buffer_len() + outer_vlan_repr.buffer_len()..],
+        );
+        inner_vlan_repr.emit(&mut frame);
+
+        let mut frame = ArpPacket::new_unchecked(
+            &mut buffer[ethernet_repr.buffer_len()
+                + outer_vlan_repr.buffer_len()
+                + inner_vlan_repr.buffer_len()..],
+        );
+        frame_repr.emit(&mut frame);
+
+        &buffer[..ethernet_repr.buffer_len()
+            + frame_repr.buffer_len()
+            + outer_vlan_repr.buffer_len()
+            + inner_vlan_repr.buffer_len()]
+    }
+
+    let (mut iface, mut sockets, _) = setup(medium);
+
+    assert!(iface
+        .inner
+        .process_ethernet(
+            &mut sockets,
+            PacketMeta::default(),
+            ETHERNET_FRAME_ARP(buffer.as_mut()),
+            &mut iface.fragments,
+        )
+        .is_none());
+
+    // Accept any IP address
+    iface.set_any_ip(true);
+
+    assert!(iface
+        .inner
+        .process_ethernet(
+            &mut sockets,
+            PacketMeta::default(),
+            ETHERNET_FRAME_ARP(buffer.as_mut()),
+            &mut iface.fragments,
+        )
+        .is_some());
+}
+
 #[rstest]
 #[case(Medium::Ip)]
 #[cfg(feature = "medium-ip")]
@@ -406,7 +491,7 @@ fn test_handle_ipv4_broadcast(#[case] medium: Medium) {
 
 #[rstest]
 #[case(Medium::Ethernet)]
-#[cfg(feature = "medium-ethernet")]
+#[cfg(all(feature = "medium-ethernet", not(feature = "proto-vlan")))]
 fn test_handle_valid_arp_request(#[case] medium: Medium) {
     let (mut iface, mut sockets, _device) = setup(medium);
 
@@ -512,7 +597,7 @@ fn test_handle_other_arp_request(#[case] medium: Medium) {
 
 #[rstest]
 #[case(Medium::Ethernet)]
-#[cfg(feature = "medium-ethernet")]
+#[cfg(all(feature = "medium-ethernet", not(feature = "proto-vlan")))]
 fn test_arp_flush_after_update_ip(#[case] medium: Medium) {
     let (mut iface, mut sockets, _device) = setup(medium);
 
@@ -676,7 +761,22 @@ fn test_handle_igmp(#[case] medium: Medium) {
                     #[cfg(feature = "medium-ethernet")]
                     Medium::Ethernet => {
                         let eth_frame = EthernetFrame::new_checked(frame).ok()?;
-                        Ipv4Packet::new_checked(eth_frame.payload()).ok()?
+                        #[cfg(feature = "proto-vlan")]
+                        let ipv4_payload = {
+                            let vlan_packet = VlanPacket::new_checked(eth_frame.payload()).ok()?;
+                            let num_vlan_headers =
+                                if vlan_packet.ethertype() == EthernetProtocol::VlanInner {
+                                    2
+                                } else {
+                                    1
+                                };
+                            &eth_frame.payload()
+                                [VlanPacket::<&[u8]>::header_len() * num_vlan_headers..]
+                        };
+                        #[cfg(not(feature = "proto-vlan"))]
+                        let ipv4_payload = eth_frame.payload();
+
+                        Ipv4Packet::new_checked(ipv4_payload).ok()?
                     }
                     #[cfg(feature = "medium-ip")]
                     Medium::Ip => Ipv4Packet::new_checked(&frame[..]).ok()?,
diff --git a/src/iface/interface/tests/ipv6.rs b/src/iface/interface/tests/ipv6.rs
index f7debda9f..97cbe0789 100644
--- a/src/iface/interface/tests/ipv6.rs
+++ b/src/iface/interface/tests/ipv6.rs
@@ -678,7 +678,7 @@ fn ndsic_neighbor_advertisement_ieee802154(#[case] medium: Medium) {
 
 #[rstest]
 #[case(Medium::Ethernet)]
-#[cfg(feature = "medium-ethernet")]
+#[cfg(all(feature = "medium-ethernet", not(feature = "proto-vlan")))]
 fn test_handle_valid_ndisc_request(#[case] medium: Medium) {
     let (mut iface, mut sockets, _device) = setup(medium);
 
diff --git a/src/tests.rs b/src/tests.rs
index ec026ab64..a4279b8cd 100644
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -4,7 +4,8 @@ use crate::wire::*;
 pub(crate) fn setup<'a>(medium: Medium) -> (Interface, SocketSet<'a>, TestingDevice) {
     let mut device = TestingDevice::new(medium);
 
-    let config = Config::new(match medium {
+    #[allow(unused_mut)]
+    let mut config = Config::new(match medium {
         #[cfg(feature = "medium-ethernet")]
         Medium::Ethernet => {
             HardwareAddress::Ethernet(EthernetAddress([0x02, 0x02, 0x02, 0x02, 0x02, 0x02]))
@@ -17,6 +18,14 @@ pub(crate) fn setup<'a>(medium: Medium) -> (Interface, SocketSet<'a>, TestingDev
         ])),
     });
 
+    #[cfg(feature = "proto-vlan")]
+    {
+        config.vlan_config = Some(VlanConfig {
+            inner_vlan_id: 100,
+            outer_vlan_id: Some(200),
+        });
+    }
+
     let mut iface = Interface::new(config, &mut device, Instant::ZERO);
 
     #[cfg(feature = "proto-ipv4")]
diff --git a/src/wire/ethernet.rs b/src/wire/ethernet.rs
index 110e3d37b..3c58e469f 100644
--- a/src/wire/ethernet.rs
+++ b/src/wire/ethernet.rs
@@ -8,7 +8,9 @@ enum_with_unknown! {
     pub enum EtherType(u16) {
         Ipv4 = 0x0800,
         Arp  = 0x0806,
-        Ipv6 = 0x86DD
+        Ipv6 = 0x86DD,
+        VlanInner = 0x8100,
+        VlanOuter = 0x88A8
     }
 }
 
@@ -18,6 +20,8 @@ impl fmt::Display for EtherType {
             EtherType::Ipv4 => write!(f, "IPv4"),
             EtherType::Ipv6 => write!(f, "IPv6"),
             EtherType::Arp => write!(f, "ARP"),
+            EtherType::VlanInner => write!(f, "Inner VLAN"),
+            EtherType::VlanOuter => write!(f, "Outer VLAN"),
             EtherType::Unknown(id) => write!(f, "0x{id:04x}"),
         }
     }
@@ -223,39 +227,62 @@ impl<T: AsRef<[u8]>> fmt::Display for Frame<T> {
 
 use crate::wire::pretty_print::{PrettyIndent, PrettyPrint};
 
-impl<T: AsRef<[u8]>> PrettyPrint for Frame<T> {
-    fn pretty_print(
-        buffer: &dyn AsRef<[u8]>,
+impl<T: AsRef<[u8]>> Frame<T> {
+    fn pretty_print_ethertype(
+        payload: &dyn AsRef<[u8]>,
         f: &mut fmt::Formatter,
         indent: &mut PrettyIndent,
+        ethertype: EtherType,
     ) -> fmt::Result {
-        let frame = match Frame::new_checked(buffer) {
-            Err(err) => return write!(f, "{indent}({err})"),
-            Ok(frame) => frame,
-        };
-        write!(f, "{indent}{frame}")?;
-
-        match frame.ethertype() {
+        match ethertype {
             #[cfg(feature = "proto-ipv4")]
             EtherType::Arp => {
                 indent.increase(f)?;
-                super::ArpPacket::<&[u8]>::pretty_print(&frame.payload(), f, indent)
+                super::ArpPacket::<&[u8]>::pretty_print(&payload, f, indent)
             }
             #[cfg(feature = "proto-ipv4")]
             EtherType::Ipv4 => {
                 indent.increase(f)?;
-                super::Ipv4Packet::<&[u8]>::pretty_print(&frame.payload(), f, indent)
+                super::Ipv4Packet::<&[u8]>::pretty_print(&payload, f, indent)
             }
             #[cfg(feature = "proto-ipv6")]
             EtherType::Ipv6 => {
                 indent.increase(f)?;
-                super::Ipv6Packet::<&[u8]>::pretty_print(&frame.payload(), f, indent)
+                super::Ipv6Packet::<&[u8]>::pretty_print(&payload, f, indent)
+            }
+            #[cfg(feature = "proto-vlan")]
+            EtherType::VlanOuter | EtherType::VlanInner => {
+                indent.increase(f)?;
+                super::VlanPacket::<&[u8]>::pretty_print(&payload, f, indent)?;
+                let vlan_packet = super::VlanPacket::new_unchecked(&payload);
+                Frame::<&[u8]>::pretty_print_ethertype(
+                    &&payload.as_ref()[super::VlanPacket::<&[u8]>::header_len()..],
+                    f,
+                    indent,
+                    vlan_packet.ethertype(),
+                )
             }
             _ => Ok(()),
         }
     }
 }
 
+impl<T: AsRef<[u8]>> PrettyPrint for Frame<T> {
+    fn pretty_print(
+        buffer: &dyn AsRef<[u8]>,
+        f: &mut fmt::Formatter,
+        indent: &mut PrettyIndent,
+    ) -> fmt::Result {
+        let frame = match Frame::new_checked(buffer) {
+            Err(err) => return write!(f, "{indent}({err})"),
+            Ok(frame) => frame,
+        };
+        write!(f, "{indent}{frame}")?;
+
+        Frame::<&[u8]>::pretty_print_ethertype(&frame.payload(), f, indent, frame.ethertype())
+    }
+}
+
 /// A high-level representation of an Internet Protocol version 4 packet header.
 #[derive(Debug, PartialEq, Eq, Clone, Copy)]
 #[cfg_attr(feature = "defmt", derive(defmt::Format))]
diff --git a/src/wire/mod.rs b/src/wire/mod.rs
index e73e2305f..6bd534bcd 100644
--- a/src/wire/mod.rs
+++ b/src/wire/mod.rs
@@ -128,6 +128,8 @@ mod rpl;
 mod sixlowpan;
 mod tcp;
 mod udp;
+#[cfg(feature = "proto-vlan")]
+mod vlan;
 
 #[cfg(feature = "proto-ipsec-ah")]
 mod ipsec_ah;
@@ -272,6 +274,9 @@ pub use self::dhcpv4::{
     MAX_DNS_SERVER_COUNT as DHCP_MAX_DNS_SERVER_COUNT, SERVER_PORT as DHCP_SERVER_PORT,
 };
 
+#[cfg(feature = "proto-vlan")]
+pub use self::vlan::{Packet as VlanPacket, Pcp, Repr as VlanRepr, VlanConfig};
+
 #[cfg(feature = "proto-dns")]
 pub use self::dns::{
     Flags as DnsFlags, Opcode as DnsOpcode, Packet as DnsPacket, Question as DnsQuestion,
diff --git a/src/wire/vlan.rs b/src/wire/vlan.rs
new file mode 100644
index 000000000..1393b3c02
--- /dev/null
+++ b/src/wire/vlan.rs
@@ -0,0 +1,375 @@
+use byteorder::{ByteOrder, NetworkEndian};
+use core::fmt;
+
+use super::ethernet::EtherType;
+use super::{Error, Result};
+
+enum_with_unknown! {
+    /// Priority code point.
+    pub enum Pcp(u8) {
+        Bk = 1, // lowest
+        Be = 0, // default
+        Ee = 2,
+        Ca = 3,
+        Vi = 4,
+        Vo = 5,
+        Ic = 6,
+        NC = 7, // highest
+    }
+}
+
+/// A struct holding VLAN configuration parameters
+#[derive(Debug, PartialEq, Eq, Copy, Clone)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+pub struct VlanConfig {
+    pub inner_vlan_id: u16,
+    pub outer_vlan_id: Option<u16>,
+}
+
+impl VlanConfig {
+    pub fn get_additional_header_length(&self) -> usize {
+        if self.outer_vlan_id.is_some() {
+            2 * HEADER_LEN
+        } else {
+            HEADER_LEN
+        }
+    }
+
+    pub(crate) fn emit_to_payload(&self, payload: &mut [u8], ethertype: EtherType) {
+        let mut inner_header = if let Some(outer_vlan_id) = self.outer_vlan_id {
+            let mut outer_header = Packet::new_unchecked(&mut payload[..]);
+            let outer_header_repr = Repr {
+                vlan_identifier: outer_vlan_id,
+                drop_eligible_indicator: false,
+                priority_code_point: Pcp::Be,
+                ethertype: EtherType::VlanInner,
+            };
+            outer_header_repr.emit(&mut outer_header);
+            Packet::new_unchecked(&mut payload[HEADER_LEN..])
+        } else {
+            Packet::new_unchecked(payload)
+        };
+
+        let inner_header_repr = Repr {
+            vlan_identifier: self.inner_vlan_id,
+            drop_eligible_indicator: false,
+            priority_code_point: Pcp::Be,
+            ethertype,
+        };
+        inner_header_repr.emit(&mut inner_header);
+    }
+
+    pub(crate) fn get_outer_ethertype(&self) -> EtherType {
+        if self.outer_vlan_id.is_some() {
+            EtherType::VlanOuter
+        } else {
+            EtherType::VlanInner
+        }
+    }
+}
+
+/// A read/write wrapper around a VLAN header.
+#[derive(Debug, PartialEq, Eq, Clone)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+pub struct Packet<T: AsRef<[u8]>> {
+    buffer: T,
+}
+
+//  VLAN according to IEEE 802.1Q adds 4 bytes after the source MAC address and EtherType of an
+//  Ethernet frame as follows:
+//
+//  ```txt
+//    0                   1                   2                   3
+//    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+//   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+//   |         TPID (0x8100)         | PCP |D|        VLAN ID        |
+//   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+//  ```
+//
+//  The first two bytes are the Tag Protocol Identifier (TPID) and the last two are the
+//  Tag Control Information (TCI).
+//
+//  IEEE 802.1ad adds the concept of double tagging which allows an outer header with a
+//  TPID of 0x88A8 in front of the IEEE 802.1Q header.
+//
+//  For simplicity it is practical to treat the TPID as EtherType of the standard Ethernet
+//  header. One can then handle VLAN as a normal Ethernet protocol with the TCI as first field
+//  followed by the EtherType of the next protocol:
+//
+//  ```txt
+//    0                   1                   2                   3
+//    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+//   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+//   | PCP |D|        VLAN ID        |           EtherType           |
+//   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+//  ```
+//
+mod field {
+    #![allow(non_snake_case)]
+
+    use crate::wire::field::*;
+
+    pub const TCI: Field = 0..2;
+    pub const ETHERTYPE: Field = 2..4;
+    pub const PAYLOAD: Rest = 4..;
+}
+
+/// The VLAN header length
+pub const HEADER_LEN: usize = field::PAYLOAD.start;
+
+impl<T: AsRef<[u8]>> Packet<T> {
+    /// Imbue a raw octet buffer with VLAN header structure.
+    pub const fn new_unchecked(buffer: T) -> Packet<T> {
+        Packet { buffer }
+    }
+
+    /// Shorthand for a combination of [new_unchecked] and [check_len].
+    ///
+    /// [new_unchecked]: #method.new_unchecked
+    /// [check_len]: #method.check_len
+    pub fn new_checked(buffer: T) -> Result<Packet<T>> {
+        let packet = Self::new_unchecked(buffer);
+        packet.check_len()?;
+        Ok(packet)
+    }
+
+    /// Ensure that no accessor method will panic if called.
+    /// Returns `Err(Error)` if the buffer is shorter than four bytes.
+    ///
+    pub fn check_len(&self) -> Result<()> {
+        let len = self.buffer.as_ref().len();
+        if len < HEADER_LEN {
+            Err(Error)
+        } else {
+            Ok(())
+        }
+    }
+
+    /// Return the length of a VLAN header.
+    pub const fn header_len() -> usize {
+        HEADER_LEN
+    }
+
+    /// Consume the packet, returning the underlying buffer.
+    pub fn into_inner(self) -> T {
+        self.buffer
+    }
+
+    /// Return the TCI field.
+    #[inline]
+    pub fn tag_control_information(&self) -> u16 {
+        let data = self.buffer.as_ref();
+        NetworkEndian::read_u16(&data[field::TCI])
+    }
+
+    /// Return the VID field.
+    #[inline]
+    pub fn vlan_identifier(&self) -> u16 {
+        let data = self.buffer.as_ref();
+        NetworkEndian::read_u16(&data[field::TCI]) & 0xfff
+    }
+
+    /// Return the DEI flag.
+    #[inline]
+    pub fn drop_eligible_indicator(&self) -> bool {
+        let data = self.buffer.as_ref();
+        NetworkEndian::read_u16(&data[field::TCI]) & 0x1000 != 0
+    }
+
+    /// Return the PCP field.
+    #[inline]
+    pub fn priority_code_point(&self) -> Pcp {
+        let data = self.buffer.as_ref();
+        let raw = data[field::TCI.start] & (0xe0_u8 >> 5);
+        Pcp::from(raw)
+    }
+
+    /// Return the EtherType field
+    #[inline]
+    pub fn ethertype(&self) -> EtherType {
+        let data = self.buffer.as_ref();
+        let raw = NetworkEndian::read_u16(&data[field::ETHERTYPE]);
+        EtherType::from(raw)
+    }
+}
+
+impl<T: AsRef<[u8]> + AsMut<[u8]>> Packet<T> {
+    /// Set the TCI field.
+    #[inline]
+    pub fn set_tag_control_information(&mut self, value: u16) {
+        let data = self.buffer.as_mut();
+        NetworkEndian::write_u16(&mut data[field::TCI], value);
+    }
+
+    /// Set the VID field.
+    #[inline]
+    pub fn set_vlan_identifier(&mut self, value: u16) {
+        let data = self.buffer.as_mut();
+        let tci = NetworkEndian::read_u16(&data[field::TCI]);
+        let raw = (tci & 0xf000) | (value & !0xf000);
+        NetworkEndian::write_u16(&mut data[field::TCI], raw)
+    }
+
+    /// Set the DEI flag.
+    #[inline]
+    pub fn set_drop_eligible_indicator(&mut self, value: bool) {
+        let data = self.buffer.as_mut();
+        let raw = data[field::TCI.start];
+        data[field::TCI.start] = if value { raw | 0x10 } else { raw & !0x10 };
+    }
+
+    /// Set the PCP field.
+    #[inline]
+    pub fn set_priority_code_point(&mut self, value: Pcp) {
+        let data = self.buffer.as_mut();
+        let raw = data[field::TCI.start];
+        data[field::TCI.start] = (raw & 0x1f) | (u8::from(value) << 5);
+    }
+
+    /// Set the EtherType field.
+    #[inline]
+    pub fn set_ethertype(&mut self, value: EtherType) {
+        let data = self.buffer.as_mut();
+        NetworkEndian::write_u16(&mut data[field::ETHERTYPE], value.into())
+    }
+}
+
+impl<T: AsRef<[u8]>> AsRef<[u8]> for Packet<T> {
+    fn as_ref(&self) -> &[u8] {
+        self.buffer.as_ref()
+    }
+}
+
+/// A high-level representation of a VLAN header.
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+pub struct Repr {
+    pub vlan_identifier: u16,
+    pub drop_eligible_indicator: bool,
+    pub priority_code_point: Pcp,
+    pub ethertype: EtherType,
+}
+
+impl Repr {
+    /// Parse a VLAN header and return a high-level representation,
+    /// or return `Err(Error)` if the packet is not recognized.
+    pub fn parse<T: AsRef<[u8]>>(packet: &Packet<T>) -> Result<Repr> {
+        packet.check_len()?;
+
+        Ok(Repr {
+            vlan_identifier: packet.vlan_identifier(),
+            drop_eligible_indicator: packet.drop_eligible_indicator(),
+            priority_code_point: packet.priority_code_point(),
+            ethertype: packet.ethertype(),
+        })
+    }
+
+    /// Return the length of a packet that will be emitted from this high-level representation.
+    pub const fn buffer_len(&self) -> usize {
+        HEADER_LEN
+    }
+
+    /// Emit a high-level representation into an VLAN header.
+    pub fn emit<T: AsRef<[u8]> + AsMut<[u8]>>(&self, packet: &mut Packet<T>) {
+        assert!(packet.buffer.as_ref().len() >= self.buffer_len());
+        packet.set_vlan_identifier(self.vlan_identifier);
+        packet.set_drop_eligible_indicator(self.drop_eligible_indicator);
+        packet.set_priority_code_point(self.priority_code_point);
+        packet.set_ethertype(self.ethertype);
+    }
+}
+
+impl<T: AsRef<[u8]>> fmt::Display for Packet<T> {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match Repr::parse(self) {
+            Ok(repr) => write!(f, "{repr}"),
+            _ => {
+                write!(f, "VLAN (unrecognized)")?;
+                write!(
+                    f,
+                    " vid={:?} dei={:?} pcp={:?} ethetype={:?}",
+                    self.vlan_identifier(),
+                    self.drop_eligible_indicator(),
+                    self.priority_code_point(),
+                    self.ethertype(),
+                )?;
+                Ok(())
+            }
+        }
+    }
+}
+
+impl fmt::Display for Repr {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        write!(
+            f,
+            "VLAN vid={} ethertype={}",
+            self.vlan_identifier, self.ethertype,
+        )
+    }
+}
+
+use crate::wire::pretty_print::{PrettyIndent, PrettyPrint};
+
+impl<T: AsRef<[u8]>> PrettyPrint for Packet<T> {
+    fn pretty_print(
+        buffer: &dyn AsRef<[u8]>,
+        f: &mut fmt::Formatter,
+        indent: &mut PrettyIndent,
+    ) -> fmt::Result {
+        match Packet::new_checked(buffer) {
+            Err(err) => write!(f, "{indent}({err})"),
+            Ok(packet) => write!(f, "{indent}{packet}"),
+        }
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    static PACKET_BYTES: [u8; 4] = [0x00, 0x64, 0x08, 0x06];
+
+    #[test]
+    fn test_deconstruct() {
+        let packet = Packet::new_unchecked(&PACKET_BYTES[..]);
+        assert_eq!(packet.priority_code_point(), Pcp::Be);
+        assert!(!packet.drop_eligible_indicator());
+        assert_eq!(packet.vlan_identifier(), 100);
+    }
+
+    #[test]
+    fn test_construct() {
+        let mut bytes = vec![0xa5; 4];
+        let mut packet = Packet::new_unchecked(&mut bytes);
+        packet.set_priority_code_point(Pcp::Be);
+        packet.set_drop_eligible_indicator(false);
+        packet.set_vlan_identifier(100);
+        packet.set_ethertype(EtherType::Arp);
+        assert_eq!(&*packet.into_inner(), &PACKET_BYTES[..]);
+    }
+
+    fn packet_repr() -> Repr {
+        Repr {
+            vlan_identifier: 100,
+            drop_eligible_indicator: false,
+            priority_code_point: Pcp::Be,
+            ethertype: EtherType::Arp,
+        }
+    }
+
+    #[test]
+    fn test_parse() {
+        let packet = Packet::new_unchecked(&PACKET_BYTES[..]);
+        let repr = Repr::parse(&packet).unwrap();
+        assert_eq!(repr, packet_repr());
+    }
+
+    #[test]
+    fn test_emit() {
+        let mut bytes = vec![0xa5; 4];
+        let mut packet = Packet::new_unchecked(&mut bytes);
+        packet_repr().emit(&mut packet);
+        assert_eq!(&*packet.into_inner(), &PACKET_BYTES[..HEADER_LEN]);
+    }
+}